octp-python 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
octp/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ OCTP — Open Contribution Trust Protocol
3
+ Reference implementation v0.2
4
+
5
+ https://github.com/openoctp/spec
6
+ """
7
+
8
+ __version__ = "0.2.0"
9
+ __spec_version__ = "0.1"
octp/cli/__init__.py ADDED
File without changes
octp/cli/init.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.prompt import Confirm
8
+
9
+ console = Console()
10
+
11
+ DEFAULT_CONFIG = """\
12
+ [policy]
13
+ require_envelope = true
14
+ minimum_review_level = "moderate_review"
15
+ block_on_failed_tests = true
16
+ allow_unreviewed_ai = false
17
+
18
+ [runners]
19
+ test_runner = "pytest"
20
+ static_analysis = "semgrep"
21
+ dependency_check = "pip-audit"
22
+
23
+ [identity]
24
+ require_signed_envelope = true
25
+ key_registry = "github"
26
+ """
27
+
28
+
29
+ def init_command(
30
+ path: Path = typer.Argument(
31
+ Path("."), help="Repository path to initialise OCTP in"
32
+ ),
33
+ ):
34
+ """Initialise OCTP in a repository — creates .octp.toml"""
35
+
36
+ config_path = path / ".octp.toml"
37
+
38
+ if config_path.exists():
39
+ overwrite = Confirm.ask(
40
+ f".octp.toml already exists at {config_path}. Overwrite?"
41
+ )
42
+ if not overwrite:
43
+ console.print("Aborted.")
44
+ raise typer.Exit(0)
45
+
46
+ config_path.write_text(DEFAULT_CONFIG)
47
+ console.print(f"\n[green]✓[/green] Created {config_path}")
48
+ console.print("\nNext steps:")
49
+ console.print(" 1. Review and adjust .octp.toml for your project")
50
+ console.print(" 2. Run [cyan]octp sign[/cyan] before submitting a pull request")
51
+ console.print(
52
+ " 3. Add to your CONTRIBUTING.md that contributors should run octp sign"
53
+ )
54
+ console.print("\nSpec: https://github.com/openoctp/spec")
octp/cli/main.py ADDED
@@ -0,0 +1,19 @@
1
+ import typer
2
+
3
+ from octp.cli.init import init_command
4
+ from octp.cli.sign import sign_command
5
+ from octp.cli.verify import verify_command
6
+
7
+ app = typer.Typer(
8
+ name="octp",
9
+ help="Open Contribution Trust Protocol — generate and verify trust envelopes",
10
+ add_completion=False,
11
+ )
12
+
13
+ app.command(name="sign")(sign_command)
14
+ app.command(name="verify")(verify_command)
15
+ app.command(name="init")(init_command)
16
+
17
+
18
+ if __name__ == "__main__":
19
+ app()
octp/cli/sign.py ADDED
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from octp.core.builder import build_envelope
10
+ from octp.git.reader import read_repo
11
+ from octp.identity.keymanager import ensure_keypair
12
+ from octp.identity.resolver import resolve_developer_id
13
+ from octp.output.formatter import (
14
+ print_envelope_summary,
15
+ print_header,
16
+ print_success,
17
+ print_verification_results,
18
+ )
19
+ from octp.provenance.collector import collect_interactively
20
+ from octp.verification.registry import run_all
21
+
22
+ console = Console()
23
+
24
+
25
+ def get_default_provenance() -> dict:
26
+ """Get default provenance data for non-interactive mode.
27
+
28
+ For OCTP project: defaults to AI-assisted with substantial review.
29
+ """
30
+ return {
31
+ "method": "ai_assisted_human_reviewed",
32
+ "ai_tools": [
33
+ {
34
+ "model": "claude-sonnet-4-6",
35
+ "vendor": "anthropic",
36
+ "version": "20260226",
37
+ "usage_type": "architecture_and_implementation",
38
+ },
39
+ {
40
+ "model": "kimi-k2.5",
41
+ "vendor": "moonshot",
42
+ "version": "20260226",
43
+ "usage_type": "implementation_and_scaffolding",
44
+ },
45
+ ],
46
+ "human_review_level": "substantial_modification",
47
+ "human_review_duration_minutes": None,
48
+ "optional_context": {},
49
+ }
50
+
51
+
52
+ def is_interactive() -> bool:
53
+ """Check if running in an interactive terminal."""
54
+ return sys.stdin.isatty() and sys.stdout.isatty()
55
+
56
+
57
+ def sign_command(
58
+ output: Path = typer.Option(
59
+ Path(".octp-envelope.json"),
60
+ "--output",
61
+ "-o",
62
+ help="Path to write the envelope JSON",
63
+ ),
64
+ yes: bool = typer.Option(
65
+ False, "--yes", "-y", help="Skip interactive prompts — use defaults"
66
+ ),
67
+ profile: str = typer.Option(
68
+ "full",
69
+ "--profile",
70
+ "-p",
71
+ help="Runner profile: fast (3-8s), full (all checks), ci, security",
72
+ ),
73
+ ):
74
+ """Generate and sign a trust envelope for the current commit."""
75
+
76
+ print_header()
77
+
78
+ # Read git state
79
+ try:
80
+ repo_info = read_repo()
81
+ except RuntimeError as e:
82
+ console.print(f"[red]Error:[/red] {e}")
83
+ raise typer.Exit(1)
84
+
85
+ console.print(f"\n Repository : [cyan]{repo_info.repository}[/cyan]")
86
+ console.print(f" Commit : [cyan]{repo_info.commit_hash[:12]}[/cyan]")
87
+ console.print(f" Profile : [cyan]{profile}[/cyan]")
88
+
89
+ # Resolve identity
90
+ ensure_keypair()
91
+ developer_id = resolve_developer_id()
92
+ console.print(f" Developer : [cyan]{developer_id}[/cyan]\n")
93
+
94
+ # Run verification checks
95
+ check_results = run_all(repo_info.root, profile=profile)
96
+ print_verification_results(check_results)
97
+
98
+ # Collect provenance declaration
99
+ if yes:
100
+ # Explicit --yes flag: use defaults
101
+ provenance_data = get_default_provenance()
102
+ console.print("\n[dim]Using default provenance (non-interactive mode)[/dim]")
103
+ elif not is_interactive():
104
+ # No TTY detected: use defaults with warning
105
+ provenance_data = get_default_provenance()
106
+ console.print(
107
+ "\n[yellow]Warning:[/yellow] Non-interactive mode detected. "
108
+ "Using default provenance. Use --yes to suppress this warning."
109
+ )
110
+ else:
111
+ # Interactive: collect from user
112
+ try:
113
+ provenance_data = collect_interactively()
114
+ except Exception as e:
115
+ console.print(f"\n[red]Error collecting input:[/red] {e}")
116
+ console.print("[dim]Falling back to default provenance...[/dim]")
117
+ provenance_data = get_default_provenance()
118
+
119
+ # Build and sign envelope
120
+ envelope = build_envelope(
121
+ repo_info=repo_info,
122
+ developer_id=developer_id,
123
+ provenance_data=provenance_data,
124
+ check_results=check_results,
125
+ )
126
+
127
+ # Write envelope
128
+ envelope_json = envelope.model_dump_json(indent=2)
129
+ output.write_text(envelope_json)
130
+
131
+ # Print summary
132
+ print_envelope_summary(envelope)
133
+ print_success(str(output))
octp/cli/verify.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from octp.core.envelope import OCTPEnvelope
10
+ from octp.integrity.hasher import hash_payload
11
+ from octp.output.formatter import print_header, print_verify_result
12
+
13
+ console = Console()
14
+
15
+
16
+ def verify_command(
17
+ envelope_path: Path = typer.Argument(
18
+ ..., help="Path to the envelope JSON file to verify"
19
+ ),
20
+ ):
21
+ """Verify a trust envelope — check integrity and signature."""
22
+
23
+ print_header()
24
+
25
+ if not envelope_path.exists():
26
+ console.print(f"[red]Error:[/red] Envelope file not found: {envelope_path}")
27
+ raise typer.Exit(1)
28
+
29
+ try:
30
+ data = json.loads(envelope_path.read_text())
31
+ envelope = OCTPEnvelope.model_validate(data)
32
+ except Exception as e:
33
+ print_verify_result(False, f"Could not parse envelope: {e}")
34
+ raise typer.Exit(1)
35
+
36
+ if not envelope.integrity:
37
+ print_verify_result(False, "No integrity section found in envelope")
38
+ raise typer.Exit(1)
39
+
40
+ # Recompute payload hash
41
+ payload_dict = envelope.to_signable_dict()
42
+ computed_hash = hash_payload(payload_dict)
43
+
44
+ if computed_hash != envelope.integrity.payload_hash:
45
+ print_verify_result(
46
+ False, "Payload hash mismatch — envelope has been tampered with"
47
+ )
48
+ raise typer.Exit(1)
49
+
50
+ # Note: full signature verification requires public key lookup
51
+ # In v0.1 we verify the hash integrity and flag if signature is present
52
+ console.print(f"\n Envelope : [cyan]{envelope_path}[/cyan]")
53
+ console.print(f" Developer : [cyan]{envelope.provenance.developer_id}[/cyan]")
54
+ console.print(f" Method : [cyan]{envelope.provenance.method.value}[/cyan]")
55
+ console.print(f" Commit : [cyan]{envelope.commit_hash[:12]}[/cyan]")
56
+
57
+ print_verify_result(True)
58
+ console.print(
59
+ "\n[dim]Note: v0.1 verifies payload integrity. "
60
+ "Full signature verification against public key registry coming in v0.2.[/dim]"
61
+ )
octp/core/__init__.py ADDED
File without changes
octp/core/builder.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime, timezone
5
+
6
+ from octp.core.envelope import (
7
+ AnalysisResult,
8
+ Integrity,
9
+ OCTPEnvelope,
10
+ OptionalContext,
11
+ Provenance,
12
+ Verification,
13
+ )
14
+ from octp.git.reader import RepoInfo
15
+ from octp.identity.keymanager import sign_payload
16
+ from octp.integrity.hasher import hash_payload
17
+ from octp.verification.base import CheckResult
18
+
19
+
20
+ def build_envelope(
21
+ repo_info: RepoInfo,
22
+ developer_id: str,
23
+ provenance_data: dict,
24
+ check_results: dict[str, CheckResult],
25
+ ) -> OCTPEnvelope:
26
+ """Assemble a complete signed OCTPEnvelope."""
27
+
28
+ # Build provenance
29
+ ai_tools = provenance_data.get("ai_tools")
30
+ provenance = Provenance(
31
+ method=provenance_data["method"],
32
+ ai_tools=ai_tools,
33
+ human_review_level=provenance_data["human_review_level"],
34
+ human_review_duration_minutes=provenance_data.get(
35
+ "human_review_duration_minutes"
36
+ ),
37
+ developer_id=developer_id,
38
+ )
39
+
40
+ # Build verification from check results
41
+ tests_result = check_results.get("pytest")
42
+ static_result = check_results.get("semgrep") or check_results.get("bandit")
43
+ deps_result = check_results.get("pip-audit")
44
+
45
+ verification = Verification(
46
+ tests_passed=tests_result.passed if tests_result else None,
47
+ test_suite_hash=tests_result.suite_hash if tests_result else None,
48
+ static_analysis=AnalysisResult(
49
+ "passed"
50
+ if static_result and static_result.passed
51
+ else "failed"
52
+ if static_result and not static_result.passed
53
+ else "skipped"
54
+ ),
55
+ static_analysis_tool=static_result.tool_name if static_result else None,
56
+ dependency_check=AnalysisResult(
57
+ "passed"
58
+ if deps_result and deps_result.passed
59
+ else "failed"
60
+ if deps_result and not deps_result.passed
61
+ else "skipped"
62
+ ),
63
+ novel_dependencies_introduced=False, # v0.1: always false, future runner
64
+ )
65
+
66
+ # Build optional context
67
+ ctx_data = provenance_data.get("optional_context", {})
68
+ optional_context = (
69
+ OptionalContext(
70
+ issue_reference=ctx_data.get("issue_reference"),
71
+ self_assessed_confidence=ctx_data.get("self_assessed_confidence"),
72
+ areas_of_uncertainty=ctx_data.get("areas_of_uncertainty"),
73
+ time_in_codebase_minutes=ctx_data.get("time_in_codebase_minutes"),
74
+ )
75
+ if ctx_data
76
+ else None
77
+ )
78
+
79
+ # Build unsigned envelope
80
+ envelope = OCTPEnvelope(
81
+ octp_version="0.1",
82
+ contribution_id=str(uuid.uuid4()),
83
+ timestamp=datetime.now(timezone.utc),
84
+ repository=repo_info.repository,
85
+ commit_hash=repo_info.commit_hash,
86
+ provenance=provenance,
87
+ verification=verification,
88
+ optional_context=optional_context,
89
+ )
90
+
91
+ # Hash and sign
92
+ payload_dict = envelope.to_signable_dict()
93
+ payload_hash = hash_payload(payload_dict)
94
+ signature = sign_payload(payload_hash)
95
+
96
+ envelope.integrity = Integrity(
97
+ payload_hash=payload_hash,
98
+ developer_signature=signature,
99
+ signature_algorithm="ES256",
100
+ signed_at=datetime.now(timezone.utc),
101
+ )
102
+
103
+ return envelope
octp/core/envelope.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ from typing import Optional
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class ProvenanceMethod(str, Enum):
11
+ HUMAN_ONLY = "human_only"
12
+ AI_ASSISTED_HUMAN_REVIEWED = "ai_assisted_human_reviewed"
13
+ AI_GENERATED_HUMAN_REVIEWED = "ai_generated_human_reviewed"
14
+ AI_GENERATED_UNREVIEWED = "ai_generated_unreviewed"
15
+
16
+
17
+ class ReviewLevel(str, Enum):
18
+ NONE = "none"
19
+ GLANCE = "glance"
20
+ MODERATE = "moderate_review"
21
+ SUBSTANTIAL = "substantial_modification"
22
+ REWRITE = "complete_rewrite"
23
+
24
+
25
+ class AnalysisResult(str, Enum):
26
+ PASSED = "passed"
27
+ FAILED = "failed"
28
+ SKIPPED = "skipped"
29
+
30
+
31
+ class Confidence(str, Enum):
32
+ LOW = "low"
33
+ MEDIUM = "medium"
34
+ HIGH = "high"
35
+
36
+
37
+ class AITool(BaseModel):
38
+ model: str
39
+ vendor: str
40
+ version: str
41
+ usage_type: str
42
+
43
+
44
+ class Provenance(BaseModel):
45
+ method: ProvenanceMethod
46
+ ai_tools: Optional[list[AITool]] = None
47
+ human_review_level: ReviewLevel
48
+ human_review_duration_minutes: Optional[int] = Field(None, ge=0)
49
+ developer_id: str
50
+
51
+
52
+ class Verification(BaseModel):
53
+ tests_passed: Optional[bool] = None # None = not run, True = passed, False = failed
54
+ test_suite_hash: Optional[str] = None
55
+ static_analysis: AnalysisResult
56
+ static_analysis_tool: Optional[str] = None
57
+ dependency_check: AnalysisResult
58
+ novel_dependencies_introduced: bool
59
+
60
+
61
+ class Integrity(BaseModel):
62
+ payload_hash: str
63
+ developer_signature: str
64
+ signature_algorithm: str = "ES256"
65
+ signed_at: datetime
66
+
67
+
68
+ class OptionalContext(BaseModel):
69
+ issue_reference: Optional[str] = None
70
+ self_assessed_confidence: Optional[Confidence] = None
71
+ areas_of_uncertainty: Optional[str] = None
72
+ time_in_codebase_minutes: Optional[int] = Field(None, ge=0)
73
+
74
+
75
+ class OCTPEnvelope(BaseModel):
76
+ octp_version: str = "0.1"
77
+ contribution_id: str
78
+ timestamp: datetime
79
+ repository: str
80
+ commit_hash: str
81
+ provenance: Provenance
82
+ verification: Verification
83
+ integrity: Optional[Integrity] = None
84
+ optional_context: Optional[OptionalContext] = None
85
+
86
+ def to_signable_dict(self) -> dict:
87
+ """Returns envelope as dict excluding the integrity section.
88
+ This is what gets hashed before signing."""
89
+ d = self.model_dump(mode="json", exclude={"integrity"})
90
+ return d
octp/core/validator.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from octp.core.envelope import OCTPEnvelope
7
+
8
+
9
+ def validate_envelope_json(data: dict) -> bool:
10
+ """Validate that a dict conforms to OCTP v0.1 envelope schema."""
11
+ try:
12
+ OCTPEnvelope.model_validate(data)
13
+ return True
14
+ except Exception:
15
+ return False
16
+
17
+
18
+ def validate_envelope_file(path: Path) -> tuple[bool, str]:
19
+ """Validate an envelope file.
20
+ Returns (is_valid, error_message)."""
21
+ try:
22
+ data = json.loads(path.read_text())
23
+ OCTPEnvelope.model_validate(data)
24
+ return True, ""
25
+ except json.JSONDecodeError as e:
26
+ return False, f"Invalid JSON: {e}"
27
+ except Exception as e:
28
+ return False, f"Schema validation failed: {e}"
octp/git/__init__.py ADDED
File without changes
octp/git/reader.py ADDED
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import git
8
+
9
+
10
+ @dataclass
11
+ class RepoInfo:
12
+ commit_hash: str
13
+ repository: str
14
+ branch: str
15
+ root: Path
16
+
17
+
18
+ def read_repo(path: Path = Path(".")) -> RepoInfo:
19
+ """Read current git repository state."""
20
+ try:
21
+ repo = git.Repo(path, search_parent_directories=True)
22
+ except git.InvalidGitRepositoryError:
23
+ raise RuntimeError(
24
+ "Not inside a git repository. Run octp from within a git project."
25
+ )
26
+
27
+ commit_hash = repo.head.commit.hexsha
28
+
29
+ # Normalise remote URL to platform/org/repo format
30
+ repository = _parse_remote(repo)
31
+
32
+ branch = repo.active_branch.name if not repo.head.is_detached else "detached"
33
+ root = Path(repo.working_dir)
34
+
35
+ return RepoInfo(
36
+ commit_hash=commit_hash,
37
+ repository=repository,
38
+ branch=branch,
39
+ root=root,
40
+ )
41
+
42
+
43
+ def _parse_remote(repo: git.Repo) -> str:
44
+ """Extract platform/org/repo from remote URL."""
45
+ try:
46
+ remote_url = repo.remotes.origin.url
47
+ except (AttributeError, IndexError):
48
+ return "unknown/unknown/unknown"
49
+
50
+ # Handle SSH: git@github.com:org/repo.git
51
+ ssh = re.match(r"git@([^:]+):(.+?)(?:\.git)?$", remote_url)
52
+ if ssh:
53
+ host = ssh.group(1)
54
+ path = ssh.group(2)
55
+ return f"{host}/{path}"
56
+
57
+ # Handle HTTPS: https://github.com/org/repo.git
58
+ https = re.match(r"https?://([^/]+)/(.+?)(?:\.git)?$", remote_url)
59
+ if https:
60
+ host = https.group(1)
61
+ path = https.group(2)
62
+ return f"{host}/{path}"
63
+
64
+ return remote_url
File without changes
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from pathlib import Path
5
+
6
+ from cryptography.hazmat.primitives import hashes, serialization
7
+ from cryptography.hazmat.primitives.asymmetric import ec
8
+
9
+ KEYS_DIR = Path.home() / ".octp" / "keys"
10
+ PRIVATE_KEY_FILE = KEYS_DIR / "private.pem"
11
+ PUBLIC_KEY_FILE = KEYS_DIR / "public.pem"
12
+
13
+
14
+ def ensure_keypair() -> None:
15
+ """Generate keypair if it doesn't exist."""
16
+ if PRIVATE_KEY_FILE.exists():
17
+ return
18
+ KEYS_DIR.mkdir(parents=True, exist_ok=True)
19
+ private_key = ec.generate_private_key(ec.SECP256R1())
20
+ public_key = private_key.public_key()
21
+
22
+ with open(PRIVATE_KEY_FILE, "wb") as f:
23
+ f.write(
24
+ private_key.private_bytes(
25
+ encoding=serialization.Encoding.PEM,
26
+ format=serialization.PrivateFormat.PKCS8,
27
+ encryption_algorithm=serialization.NoEncryption(),
28
+ )
29
+ )
30
+ PRIVATE_KEY_FILE.chmod(0o600)
31
+
32
+ with open(PUBLIC_KEY_FILE, "wb") as f:
33
+ f.write(
34
+ public_key.public_bytes(
35
+ encoding=serialization.Encoding.PEM,
36
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
37
+ )
38
+ )
39
+
40
+
41
+ def sign_payload(payload_hash: str) -> str:
42
+ """Sign a payload hash with the developer's private key.
43
+ Returns base64-encoded signature."""
44
+ ensure_keypair()
45
+ with open(PRIVATE_KEY_FILE, "rb") as f:
46
+ private_key = serialization.load_pem_private_key(f.read(), password=None)
47
+
48
+ signature = private_key.sign(
49
+ payload_hash.encode(),
50
+ ec.ECDSA(hashes.SHA256()),
51
+ )
52
+ return base64.b64encode(signature).decode()
53
+
54
+
55
+ def get_public_key_pem() -> str:
56
+ """Return the developer's public key in PEM format."""
57
+ ensure_keypair()
58
+ return PUBLIC_KEY_FILE.read_text()
59
+
60
+
61
+ def verify_signature(
62
+ payload_hash: str, signature_b64: str, public_key_pem: str
63
+ ) -> bool:
64
+ """Verify a signature against a payload hash and public key."""
65
+ try:
66
+ public_key = serialization.load_pem_public_key(public_key_pem.encode())
67
+ signature = base64.b64decode(signature_b64)
68
+ public_key.verify(
69
+ signature,
70
+ payload_hash.encode(),
71
+ ec.ECDSA(hashes.SHA256()),
72
+ )
73
+ return True
74
+ except Exception:
75
+ return False
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import git
6
+
7
+
8
+ def resolve_developer_id(repo_path: Path = Path(".")) -> str:
9
+ """Resolve developer identity from git config."""
10
+ try:
11
+ repo = git.Repo(repo_path, search_parent_directories=True)
12
+ config = repo.config_reader()
13
+
14
+ # Try to get GitHub username from git config
15
+ try:
16
+ github_user = config.get_value("github", "user", default=None)
17
+ if github_user:
18
+ return f"github:{github_user}"
19
+ except Exception:
20
+ pass
21
+
22
+ # Fall back to git user email
23
+ try:
24
+ email = config.get_value("user", "email", default=None)
25
+ if email:
26
+ return f"email:{email}"
27
+ except Exception:
28
+ pass
29
+
30
+ # Fall back to git user name
31
+ try:
32
+ name = config.get_value("user", "name", default=None)
33
+ if name:
34
+ return f"git:{name}"
35
+ except Exception:
36
+ pass
37
+
38
+ except Exception:
39
+ pass
40
+
41
+ return "unknown"
File without changes