diff --git a/features/resource_handler_content_hash.feature b/features/resource_handler_content_hash.feature new file mode 100644 index 00000000..27d54b51 --- /dev/null +++ b/features/resource_handler_content_hash.feature @@ -0,0 +1,188 @@ +@resource @handler @content_hash +Feature: ResourceHandler content_hash method + As a developer using change detection and deduplication + I want resource handlers to compute deterministic content hashes + So that the system can detect changes and deduplicate resources + + # ── Sentinel hash for empty/missing resources ────────────── + + Scenario: Missing resource returns sentinel hash + Given a resource with no location for content_hash + When I compute the content_hash via base handler + Then the content_hash result should be the EMPTY sentinel + + Scenario: Non-existent path returns sentinel hash + Given a resource with location "/nonexistent/path" for content_hash + When I compute the content_hash via base handler + Then the content_hash result should be the EMPTY sentinel + + # ── Determinism ──────────────────────────────────────────── + + Scenario: Same file produces same hash on repeated calls + Given a temporary file with content "hello world" for content_hash + When I compute the content_hash twice via base handler + Then the two content_hash results should be identical + + Scenario: Different content produces different hash + Given a temporary file with content "hello" for content_hash + And a second temporary file with content "world" for content_hash + When I compute content_hash for both files + Then the two content_hash results should differ + + # ── fs-directory recursive hash ──────────────────────────── + + Scenario: FsDirectoryHandler hashes directory recursively + Given a temporary directory with files for content_hash + When I compute the content_hash via FsDirectoryHandler + Then the content_hash result should not be the EMPTY sentinel + + Scenario: FsDirectoryHandler hash changes when file content changes + Given a temporary directory with files for content_hash + When I compute the content_hash via FsDirectoryHandler + And I modify a file in the directory + And I compute the content_hash via FsDirectoryHandler again + Then the two directory hashes should differ + + # ── git-checkout hash ────────────────────────────────────── + + Scenario: GitCheckoutHandler hashes a git repo + Given a temporary git repository for content_hash + When I compute the content_hash via GitCheckoutHandler + Then the content_hash result should not be the EMPTY sentinel + + # ── Configurable algorithm ───────────────────────────────── + + Scenario: SHA-512 algorithm produces different length hash + Given a temporary file with content "test" for content_hash + When I compute the content_hash with algorithm "sha512" + Then the content_hash length should be 128 + + Scenario: Default SHA-256 produces 64-char hash + Given a temporary file with content "test" for content_hash + When I compute the content_hash with algorithm "sha256" + Then the content_hash length should be 64 + + # ── Protocol compliance ──────────────────────────────────── + + Scenario: All handlers have content_hash method + Then GitCheckoutHandler should have a content_hash method + And FsDirectoryHandler should have a content_hash method + And DevcontainerHandler should have a content_hash method + And DatabaseResourceHandler should have a content_hash method + + # ── devcontainer content_hash ────────────────────────────── + + Scenario: DevcontainerHandler config file hash for devcontainer-file + Given a temporary devcontainer project for content_hash + When I compute the content_hash via DevcontainerHandler + Then the content_hash result should not be the EMPTY sentinel + + Scenario: DevcontainerHandler returns sentinel for missing config + Given a resource with location "/tmp/no-devcontainer" for content_hash + When I compute the content_hash via DevcontainerHandler for missing + Then the content_hash result should be the EMPTY sentinel + + Scenario: DevcontainerHandler exec hash falls back to config when no container + Given a temporary devcontainer project for content_hash + When I compute the content_hash via DevcontainerHandler + Then the devcontainer hash should be from config file fallback + + # ── database content_hash ────────────────────────────────── + + Scenario: DatabaseResourceHandler hashes SQLite schema + Given a temporary SQLite database for content_hash + When I compute the content_hash via DatabaseResourceHandler + Then the content_hash result should not be the EMPTY sentinel + + Scenario: DatabaseResourceHandler schema hash changes when table added + Given a temporary SQLite database for content_hash + When I compute the content_hash via DatabaseResourceHandler + And I add a table to the database + And I compute the content_hash via DatabaseResourceHandler again + Then the two database hashes should differ + + Scenario: DatabaseResourceHandler returns identity hash for remote DB + Given a remote database resource for content_hash + When I compute the content_hash via DatabaseResourceHandler for remote + Then the content_hash result should not be the EMPTY sentinel + + # ── cloud content_hash ───────────────────────────────────── + + Scenario: CloudResourceHandler returns identity hash with location + Given a cloud resource with ARN location for content_hash + When I compute the content_hash via CloudResourceHandler + Then the content_hash result should not be the EMPTY sentinel + + Scenario: CloudResourceHandler returns sentinel without location + Given a cloud resource without location for content_hash + When I compute the content_hash via CloudResourceHandler for empty + Then the content_hash result should be the EMPTY sentinel + + # ── Edge case and error path coverage ────────────────────── + + Scenario: Base handler hashes directory entries including subdirectory names + Given a temporary directory with only subdirectories for content_hash + When I compute the content_hash via base handler for dir + Then the content_hash result should not be the EMPTY sentinel + + Scenario: FsDirectoryHandler returns sentinel for non-directory location + Given a temporary file pretending to be a directory for content_hash + When I compute the content_hash via FsDirectoryHandler for non-dir + Then the content_hash result should be the EMPTY sentinel + + Scenario: FsDirectoryHandler skips unreadable files gracefully + Given a temporary directory with an unreadable file for content_hash + When I compute the content_hash via FsDirectoryHandler for unreadable + Then the content_hash result should not be the EMPTY sentinel + + Scenario: GitCheckoutHandler returns sentinel for non-git directory + Given a temporary non-git directory for content_hash + When I compute the content_hash via GitCheckoutHandler for non-git + Then the content_hash result should be the EMPTY sentinel + + Scenario: GitCheckoutHandler returns sentinel for file path + Given a temporary file as git location for content_hash + When I compute the content_hash via GitCheckoutHandler for file + Then the content_hash result should be the EMPTY sentinel + + Scenario: DatabaseResourceHandler returns sentinel for no location + Given a database resource with no location for content_hash + When I compute the content_hash via DatabaseResourceHandler for noloc + Then the content_hash result should be the EMPTY sentinel + + Scenario: DatabaseResourceHandler hashes non-db-extension SQLite file directly + Given a SQLite database with non-standard extension for content_hash + When I compute the content_hash via DatabaseResourceHandler for nonext + Then the content_hash result should not be the EMPTY sentinel + + Scenario: DevcontainerHandler _find_running_container returns None without docker + Given a temporary devcontainer project for content_hash + When I check _find_running_container + Then the container ID should be None + + # ── Mock-based branch coverage ───────────────────────────── + + Scenario: DevcontainerHandler exec hash success path via mock + Given a temporary devcontainer project for content_hash + When I compute content_hash with mocked docker exec returning file hashes + Then the content_hash result should not be the EMPTY sentinel + + Scenario: DevcontainerHandler _find_running_container OSError path + Given a temporary devcontainer project for content_hash + When I compute content_hash with docker ps raising OSError + Then the devcontainer falls back to config hash + + Scenario: GitCheckoutHandler returns sentinel on subprocess timeout + Given a temporary git-checkout resource for content_hash timeout + When I compute content_hash with git rev-parse timing out + Then the content_hash result should be the EMPTY sentinel + + Scenario: GitCheckoutHandler returns sentinel on OSError + Given a temporary git-checkout resource for content_hash timeout + When I compute content_hash with git rev-parse raising OSError + Then the content_hash result should be the EMPTY sentinel + + Scenario: Base handler returns sentinel for special file paths + Given a resource pointing to a non-file non-dir path for content_hash + When I compute the content_hash via base handler for special + Then the content_hash result should be the EMPTY sentinel diff --git a/features/steps/resource_handler_content_hash_steps.py b/features/steps/resource_handler_content_hash_steps.py new file mode 100644 index 00000000..2a09d441 --- /dev/null +++ b/features/steps/resource_handler_content_hash_steps.py @@ -0,0 +1,713 @@ +"""Step definitions for resource_handler_content_hash.feature.""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +from behave import given, then, when + +from cleveragents.domain.models.core.resource import PhysVirt, Resource +from cleveragents.resource.handlers._base import EMPTY_CONTENT_HASH, BaseResourceHandler +from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler +from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler + + +def _make_resource(location: str | None = None) -> Resource: + """Create a minimal Resource for testing.""" + from ulid import ULID + + return Resource( + resource_id=str(ULID()), + resource_type_name="fs-directory", + name="test-resource", + classification=PhysVirt.PHYSICAL, + location=location, + ) + + +# ── Given steps ────────────────────────────────────────────── + + +@given("a resource with no location for content_hash") +def step_resource_no_location(context: object) -> None: + """Create a resource with no location.""" + context.ch_resource = _make_resource(location=None) # type: ignore[attr-defined] + + +@given('a resource with location "{path}" for content_hash') +def step_resource_with_location(context: object, path: str) -> None: + """Create a resource with a specific location.""" + context.ch_resource = _make_resource(location=path) # type: ignore[attr-defined] + + +@given('a temporary file with content "{content}" for content_hash') +def step_temp_file(context: object, content: str) -> None: + """Create a temporary file with given content.""" + fd, path = tempfile.mkstemp(suffix=".txt") + os.write(fd, content.encode()) + os.close(fd) + context.ch_resource = _make_resource(location=path) # type: ignore[attr-defined] + context.ch_temp_path = path # type: ignore[attr-defined] + + +@given('a second temporary file with content "{content}" for content_hash') +def step_second_temp_file(context: object, content: str) -> None: + """Create a second temporary file.""" + fd, path = tempfile.mkstemp(suffix=".txt") + os.write(fd, content.encode()) + os.close(fd) + context.ch_resource2 = _make_resource(location=path) # type: ignore[attr-defined] + + +@given("a temporary directory with files for content_hash") +def step_temp_dir_with_files(context: object) -> None: + """Create a temp directory with test files.""" + tmpdir = tempfile.mkdtemp(prefix="ch-test-") + Path(tmpdir, "file_a.txt").write_text("alpha") + Path(tmpdir, "file_b.txt").write_text("beta") + Path(tmpdir, "sub").mkdir() + Path(tmpdir, "sub", "file_c.txt").write_text("gamma") + context.ch_resource = _make_resource(location=tmpdir) # type: ignore[attr-defined] + context.ch_temp_dir = tmpdir # type: ignore[attr-defined] + + +@given("a temporary git repository for content_hash") +def step_temp_git_repo(context: object) -> None: + """Create a temp git repo with a commit.""" + tmpdir = tempfile.mkdtemp(prefix="ch-git-") + subprocess.run( + ["git", "init"], + cwd=tmpdir, + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=tmpdir, + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=tmpdir, + capture_output=True, + check=True, + ) + Path(tmpdir, "README.md").write_text("# Test") + subprocess.run( + ["git", "add", "."], + cwd=tmpdir, + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "commit", "-m", "init"], + cwd=tmpdir, + capture_output=True, + check=True, + ) + context.ch_resource = _make_resource(location=tmpdir) # type: ignore[attr-defined] + + +# ── When steps ─────────────────────────────────────────────── + + +@when("I compute the content_hash via base handler") +def step_compute_base(context: object) -> None: + """Compute hash via BaseResourceHandler.""" + handler = BaseResourceHandler() + handler._default_strategy = "none" # type: ignore[assignment] + handler._type_label = "test" # type: ignore[assignment] + context.ch_result = handler.content_hash(context.ch_resource) # type: ignore[attr-defined] + + +@when("I compute the content_hash twice via base handler") +def step_compute_twice(context: object) -> None: + """Compute hash twice to verify determinism.""" + handler = BaseResourceHandler() + handler._default_strategy = "none" # type: ignore[assignment] + handler._type_label = "test" # type: ignore[assignment] + res = context.ch_resource # type: ignore[attr-defined] + context.ch_result = handler.content_hash(res) # type: ignore[attr-defined] + context.ch_result2 = handler.content_hash(res) # type: ignore[attr-defined] + + +@when("I compute content_hash for both files") +def step_compute_both(context: object) -> None: + """Compute hashes for two different resources.""" + handler = BaseResourceHandler() + handler._default_strategy = "none" # type: ignore[assignment] + handler._type_label = "test" # type: ignore[assignment] + context.ch_result = handler.content_hash(context.ch_resource) # type: ignore[attr-defined] + context.ch_result2 = handler.content_hash(context.ch_resource2) # type: ignore[attr-defined] + + +@when("I compute the content_hash via FsDirectoryHandler") +def step_compute_fsdir(context: object) -> None: + """Compute hash via FsDirectoryHandler.""" + handler = FsDirectoryHandler() + context.ch_result = handler.content_hash(context.ch_resource) # type: ignore[attr-defined] + + +@when("I modify a file in the directory") +def step_modify_file(context: object) -> None: + """Modify a file in the temp directory.""" + context.ch_prev_result = context.ch_result # type: ignore[attr-defined] + tmpdir = context.ch_temp_dir # type: ignore[attr-defined] + Path(tmpdir, "file_a.txt").write_text("alpha-modified") + + +@when("I compute the content_hash via FsDirectoryHandler again") +def step_compute_fsdir_again(context: object) -> None: + """Re-compute hash after modification.""" + handler = FsDirectoryHandler() + context.ch_result = handler.content_hash(context.ch_resource) # type: ignore[attr-defined] + + +@when("I compute the content_hash via GitCheckoutHandler") +def step_compute_git(context: object) -> None: + """Compute hash via GitCheckoutHandler.""" + handler = GitCheckoutHandler() + context.ch_result = handler.content_hash(context.ch_resource) # type: ignore[attr-defined] + + +@when('I compute the content_hash with algorithm "{algo}"') +def step_compute_with_algo(context: object, algo: str) -> None: + """Compute hash with a specific algorithm.""" + handler = BaseResourceHandler() + handler._default_strategy = "none" # type: ignore[assignment] + handler._type_label = "test" # type: ignore[assignment] + context.ch_result = handler.content_hash( # type: ignore[attr-defined] + context.ch_resource, + algorithm=algo, # type: ignore[attr-defined] + ) + + +# ── Then steps ─────────────────────────────────────────────── + + +@then("the content_hash result should be the EMPTY sentinel") +def step_is_sentinel(context: object) -> None: + """Assert the result equals EMPTY_CONTENT_HASH.""" + assert context.ch_result == EMPTY_CONTENT_HASH, ( # type: ignore[attr-defined] + f"Expected sentinel, got {context.ch_result}" # type: ignore[attr-defined] + ) + + +@then("the content_hash result should not be the EMPTY sentinel") +def step_not_sentinel(context: object) -> None: + """Assert the result is not the sentinel.""" + assert context.ch_result != EMPTY_CONTENT_HASH, ( # type: ignore[attr-defined] + "Expected non-sentinel hash" + ) + + +@then("the two content_hash results should be identical") +def step_results_identical(context: object) -> None: + """Assert two hash results are the same.""" + assert context.ch_result == context.ch_result2, ( # type: ignore[attr-defined] + f"Hashes differ: {context.ch_result} vs {context.ch_result2}" # type: ignore[attr-defined] + ) + + +@then("the two content_hash results should differ") +def step_results_differ(context: object) -> None: + """Assert two hash results are different.""" + assert context.ch_result != context.ch_result2, ( # type: ignore[attr-defined] + f"Hashes should differ but both are {context.ch_result}" # type: ignore[attr-defined] + ) + + +@then("the two directory hashes should differ") +def step_dir_hashes_differ(context: object) -> None: + """Assert directory hash changed after modification.""" + assert context.ch_prev_result != context.ch_result, ( # type: ignore[attr-defined] + "Directory hash should change after file modification" + ) + + +@then("the content_hash length should be {length:d}") +def step_hash_length(context: object, length: int) -> None: + """Assert the hash has the expected hex-string length.""" + actual = len(context.ch_result) # type: ignore[attr-defined] + assert actual == length, f"Expected length {length}, got {actual}" + + +@then("{handler_name} should have a content_hash method") +def step_has_method(context: object, handler_name: str) -> None: + """Assert the handler class has a content_hash method.""" + handlers = { + "GitCheckoutHandler": GitCheckoutHandler, + "FsDirectoryHandler": FsDirectoryHandler, + } + # Lazy imports for handlers not already imported + if handler_name == "DevcontainerHandler": + from cleveragents.resource.handlers.devcontainer import DevcontainerHandler + + handlers["DevcontainerHandler"] = DevcontainerHandler + elif handler_name == "DatabaseResourceHandler": + from cleveragents.resource.handlers.database import DatabaseResourceHandler + + handlers["DatabaseResourceHandler"] = DatabaseResourceHandler + + cls = handlers.get(handler_name) + assert cls is not None, f"Unknown handler: {handler_name}" + assert hasattr(cls, "content_hash"), f"{handler_name} missing content_hash method" + + +# --------------------------------------------------------------------------- +# DevcontainerHandler content_hash steps +# --------------------------------------------------------------------------- + + +@given("a temporary devcontainer project for content_hash") +def step_devcontainer_project(context: Any) -> None: + tmpdir = tempfile.mkdtemp() + dc_dir = os.path.join(tmpdir, ".devcontainer") + os.makedirs(dc_dir) + config = os.path.join(dc_dir, "devcontainer.json") + with open(config, "w") as f: + f.write( + '{"name": "test", "image": "mcr.microsoft.com/devcontainers/base:ubuntu"}\n' + ) + context.ch_resource = Resource( + resource_id="01DCTEST00000000000000DC01", + resource_type_name="devcontainer-file", + name="test-devcontainer", + classification=PhysVirt.PHYSICAL, + location=tmpdir, + ) + context._ch_tmpdir = tmpdir + + +@when("I compute the content_hash via DevcontainerHandler") +def step_devcontainer_hash(context: Any) -> None: + from cleveragents.resource.handlers.devcontainer import DevcontainerHandler + + handler = DevcontainerHandler() + context.ch_result = handler.content_hash(context.ch_resource) + + +@when("I compute the content_hash via DevcontainerHandler for missing") +def step_devcontainer_hash_missing(context: Any) -> None: + from cleveragents.resource.handlers.devcontainer import DevcontainerHandler + + handler = DevcontainerHandler() + context.ch_result = handler.content_hash(context.ch_resource) + + +@then("the devcontainer hash should be from config file fallback") +def step_devcontainer_config_fallback(context: Any) -> None: + # If no running container, the hash comes from config file + # Verify it's not empty (config file was hashed) + assert context.ch_result != EMPTY_CONTENT_HASH + assert len(context.ch_result) == 64 # SHA-256 + + +# --------------------------------------------------------------------------- +# DatabaseResourceHandler content_hash steps +# --------------------------------------------------------------------------- + + +@given("a temporary SQLite database for content_hash") +def step_sqlite_db(context: Any) -> None: + import sqlite3 + + fd, db_path = tempfile.mkstemp(suffix=".db") + os.close(fd) + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)") + conn.execute("INSERT INTO users VALUES (1, 'alice')") + conn.commit() + conn.close() + context.ch_resource = Resource( + resource_id="01DBTEST00000000000000DB01", + resource_type_name="sqlite", + name="test-db", + classification=PhysVirt.PHYSICAL, + location=db_path, + ) + context._ch_db_path = db_path + + +@when("I compute the content_hash via DatabaseResourceHandler") +def step_database_hash(context: Any) -> None: + from cleveragents.resource.handlers.database import DatabaseResourceHandler + + handler = DatabaseResourceHandler() + context.ch_result = handler.content_hash(context.ch_resource) + + +@when("I add a table to the database") +def step_add_table(context: Any) -> None: + import sqlite3 + + conn = sqlite3.connect(context._ch_db_path) + conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)") + conn.commit() + conn.close() + + +@when("I compute the content_hash via DatabaseResourceHandler again") +def step_database_hash_again(context: Any) -> None: + from cleveragents.resource.handlers.database import DatabaseResourceHandler + + handler = DatabaseResourceHandler() + context.ch_result2 = handler.content_hash(context.ch_resource) + + +@then("the two database hashes should differ") +def step_database_hashes_differ(context: Any) -> None: + assert context.ch_result != context.ch_result2, ( + f"Expected different hashes after schema change, both are {context.ch_result}" + ) + + +@given("a remote database resource for content_hash") +def step_remote_db_resource(context: Any) -> None: + context.ch_resource = Resource( + resource_id="01DBTEST00000000000000DB02", + resource_type_name="postgres", + name="test-remote-db", + classification=PhysVirt.PHYSICAL, + location="postgresql://host/db", + ) + + +@when("I compute the content_hash via DatabaseResourceHandler for remote") +def step_database_hash_remote(context: Any) -> None: + from cleveragents.resource.handlers.database import DatabaseResourceHandler + + handler = DatabaseResourceHandler() + context.ch_result = handler.content_hash(context.ch_resource) + + +# --------------------------------------------------------------------------- +# CloudResourceHandler content_hash steps +# --------------------------------------------------------------------------- + + +@given("a cloud resource with ARN location for content_hash") +def step_cloud_resource_with_location(context: Any) -> None: + context.ch_resource = Resource( + resource_id="01AATEST0000000000000CCC01", + resource_type_name="aws-s3", + name="test-bucket", + classification=PhysVirt.PHYSICAL, + location="arn:aws:s3:::my-bucket", + ) + + +@given("a cloud resource without location for content_hash") +def step_cloud_resource_no_location(context: Any) -> None: + context.ch_resource = Resource( + resource_id="01AATEST0000000000000CCC02", + resource_type_name="aws-s3", + name="test-bucket-noloc", + classification=PhysVirt.PHYSICAL, + ) + + +@when("I compute the content_hash via CloudResourceHandler") +def step_cloud_hash(context: Any) -> None: + from cleveragents.resource.handlers.cloud import CloudResourceHandler + + handler = CloudResourceHandler() + context.ch_result = handler.content_hash(context.ch_resource) + + +@when("I compute the content_hash via CloudResourceHandler for empty") +def step_cloud_hash_empty(context: Any) -> None: + from cleveragents.resource.handlers.cloud import CloudResourceHandler + + handler = CloudResourceHandler() + context.ch_result = handler.content_hash(context.ch_resource) + + +# --------------------------------------------------------------------------- +# Edge case and error path coverage steps +# --------------------------------------------------------------------------- + + +@given("a temporary directory with only subdirectories for content_hash") +def step_dir_only_subdirs(context: Any) -> None: + tmpdir = tempfile.mkdtemp() + os.makedirs(os.path.join(tmpdir, "subdir1")) + os.makedirs(os.path.join(tmpdir, "subdir2")) + context.ch_resource = Resource( + resource_id="01EDGE00000000000000000001", + resource_type_name="fs-directory", + name="empty-subdirs", + classification=PhysVirt.PHYSICAL, + location=tmpdir, + ) + + +@when("I compute the content_hash via base handler for dir") +def step_base_hash_dir(context: Any) -> None: + handler = BaseResourceHandler() + context.ch_result = handler.content_hash(context.ch_resource) + + +@given("a temporary file pretending to be a directory for content_hash") +def step_file_as_dir(context: Any) -> None: + fd, fpath = tempfile.mkstemp(suffix=".txt") + os.close(fd) + context.ch_resource = Resource( + resource_id="01EDGE00000000000000000002", + resource_type_name="fs-directory", + name="not-a-dir", + classification=PhysVirt.PHYSICAL, + location=fpath, + ) + + +@when("I compute the content_hash via FsDirectoryHandler for non-dir") +def step_fsdir_hash_nondir(context: Any) -> None: + handler = FsDirectoryHandler() + context.ch_result = handler.content_hash(context.ch_resource) + + +@given("a temporary directory with an unreadable file for content_hash") +def step_dir_unreadable(context: Any) -> None: + tmpdir = tempfile.mkdtemp() + good = os.path.join(tmpdir, "good.txt") + bad = os.path.join(tmpdir, "bad.txt") + with open(good, "w") as f: + f.write("readable content") + with open(bad, "w") as f: + f.write("secret") + os.chmod(bad, 0o000) + context.ch_resource = Resource( + resource_id="01EDGE00000000000000000003", + resource_type_name="fs-directory", + name="has-unreadable", + classification=PhysVirt.PHYSICAL, + location=tmpdir, + ) + context._ch_bad_file = bad + + +@when("I compute the content_hash via FsDirectoryHandler for unreadable") +def step_fsdir_hash_unreadable(context: Any) -> None: + handler = FsDirectoryHandler() + context.ch_result = handler.content_hash(context.ch_resource) + # Restore permissions for cleanup + os.chmod(context._ch_bad_file, 0o644) + + +@given("a temporary non-git directory for content_hash") +def step_nongit_dir(context: Any) -> None: + tmpdir = tempfile.mkdtemp() + # Create a corrupt .git file so git rev-parse HEAD fails even if + # the temp dir is inside a git worktree (CI environments). + with open(os.path.join(tmpdir, ".git"), "w") as f: + f.write("not a valid git dir\n") + with open(os.path.join(tmpdir, "file.txt"), "w") as f: + f.write("not a git repo") + context.ch_resource = Resource( + resource_id="01EDGE00000000000000000004", + resource_type_name="git-checkout", + name="not-git", + classification=PhysVirt.PHYSICAL, + location=tmpdir, + ) + + +@when("I compute the content_hash via GitCheckoutHandler for non-git") +def step_git_hash_nongit(context: Any) -> None: + handler = GitCheckoutHandler() + context.ch_result = handler.content_hash(context.ch_resource) + + +@given("a temporary file as git location for content_hash") +def step_file_as_git(context: Any) -> None: + fd, fpath = tempfile.mkstemp() + os.close(fd) + context.ch_resource = Resource( + resource_id="01EDGE00000000000000000005", + resource_type_name="git-checkout", + name="file-not-dir", + classification=PhysVirt.PHYSICAL, + location=fpath, + ) + + +@when("I compute the content_hash via GitCheckoutHandler for file") +def step_git_hash_file(context: Any) -> None: + handler = GitCheckoutHandler() + context.ch_result = handler.content_hash(context.ch_resource) + + +@given("a database resource with no location for content_hash") +def step_db_no_location(context: Any) -> None: + context.ch_resource = Resource( + resource_id="01EDGE00000000000000000006", + resource_type_name="sqlite", + name="db-noloc", + classification=PhysVirt.PHYSICAL, + ) + + +@when("I compute the content_hash via DatabaseResourceHandler for noloc") +def step_db_hash_noloc(context: Any) -> None: + from cleveragents.resource.handlers.database import DatabaseResourceHandler + + handler = DatabaseResourceHandler() + context.ch_result = handler.content_hash(context.ch_resource) + + +@given("a SQLite database with non-standard extension for content_hash") +def step_sqlite_nonext(context: Any) -> None: + import sqlite3 + + fd, db_path = tempfile.mkstemp(suffix=".data") + os.close(fd) + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE t (id INTEGER)") + conn.commit() + conn.close() + context.ch_resource = Resource( + resource_id="01EDGE00000000000000000007", + resource_type_name="sqlite", + name="nonext-db", + classification=PhysVirt.PHYSICAL, + location=db_path, + ) + + +@when("I compute the content_hash via DatabaseResourceHandler for nonext") +def step_db_hash_nonext(context: Any) -> None: + from cleveragents.resource.handlers.database import DatabaseResourceHandler + + handler = DatabaseResourceHandler() + context.ch_result = handler.content_hash(context.ch_resource) + + +@when("I check _find_running_container") +def step_check_find_container(context: Any) -> None: + from cleveragents.resource.handlers.devcontainer import DevcontainerHandler + + handler = DevcontainerHandler() + context._ch_container_id = handler._find_running_container(context.ch_resource) + + +@then("the container ID should be None") +def step_container_id_none(context: Any) -> None: + assert context._ch_container_id is None + + +# --------------------------------------------------------------------------- +# Mock-based branch coverage steps +# --------------------------------------------------------------------------- + + +@when("I compute content_hash with mocked docker exec returning file hashes") +def step_devcontainer_mock_exec(context: Any) -> None: + from unittest.mock import MagicMock, patch + + from cleveragents.resource.handlers.devcontainer import DevcontainerHandler + + handler = DevcontainerHandler() + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = ( + "abc123 /workspaces/project/file1.py\ndef456 /workspaces/project/file2.py\n" + ) + + with ( + patch("subprocess.run", return_value=mock_result), + patch.object(handler, "_find_running_container", return_value="abc123def456"), + ): + context.ch_result = handler.content_hash(context.ch_resource) + + +@when("I compute content_hash with docker ps raising OSError") +def step_devcontainer_oserror(context: Any) -> None: + from unittest.mock import patch + + from cleveragents.resource.handlers.devcontainer import DevcontainerHandler + + handler = DevcontainerHandler() + + with patch( + "cleveragents.resource.handlers.devcontainer.subprocess.run", + side_effect=OSError("docker not found"), + ): + context.ch_result = handler.content_hash(context.ch_resource) + + +@then("the devcontainer falls back to config hash") +def step_devcontainer_fallback(context: Any) -> None: + # Should have fallen back to config file hash (not empty) + assert context.ch_result != EMPTY_CONTENT_HASH + assert len(context.ch_result) == 64 + + +@given("a temporary git-checkout resource for content_hash timeout") +def step_git_timeout_resource(context: Any) -> None: + tmpdir = tempfile.mkdtemp() + context.ch_resource = Resource( + resource_id="01EDGEMCK000000000000000MK", + resource_type_name="git-checkout", + name="mock-git", + classification=PhysVirt.PHYSICAL, + location=tmpdir, + ) + + +@when("I compute content_hash with git rev-parse timing out") +def step_git_timeout(context: Any) -> None: + from unittest.mock import patch + + handler = GitCheckoutHandler() + + with patch( + "subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="git", timeout=10), + ): + context.ch_result = handler.content_hash(context.ch_resource) + + +@when("I compute content_hash with git rev-parse raising OSError") +def step_git_oserror(context: Any) -> None: + from unittest.mock import patch + + handler = GitCheckoutHandler() + + with patch("subprocess.run", side_effect=OSError("git not found")): + context.ch_result = handler.content_hash(context.ch_resource) + + +@given("a resource pointing to a non-file non-dir path for content_hash") +def step_special_file(context: Any) -> None: + + # Use a real temp path but mock os.path to make it exist but be neither file nor dir + tmpdir = tempfile.mkdtemp() + context.ch_resource = Resource( + resource_id="01EDGE00000000000000SPEC01", + resource_type_name="test", + name="special", + classification=PhysVirt.PHYSICAL, + location=tmpdir, + ) + context._ch_special_tmpdir = tmpdir + + +@when("I compute the content_hash via base handler for special") +def step_base_hash_special(context: Any) -> None: + from unittest.mock import patch + + handler = BaseResourceHandler() + + # Mock: path exists, but is neither file nor dir (e.g., a socket) + with ( + patch("os.path.exists", return_value=True), + patch("os.path.isfile", return_value=False), + patch("os.path.isdir", return_value=False), + ): + context.ch_result = handler.content_hash(context.ch_resource) diff --git a/robot/helper_resource_handler_content_hash.py b/robot/helper_resource_handler_content_hash.py new file mode 100644 index 00000000..2847adf1 --- /dev/null +++ b/robot/helper_resource_handler_content_hash.py @@ -0,0 +1,136 @@ +"""Robot Framework helper for ResourceHandler content_hash integration tests.""" + +from __future__ import annotations + +import importlib +import os +import sys +import tempfile +from pathlib import Path + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.resource.handlers._base import ( # noqa: E402 + EMPTY_CONTENT_HASH, + BaseResourceHandler, +) +from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler # noqa: E402 + + +def _make_resource(location: str) -> object: + """Create a minimal resource-like object with a location attribute.""" + + class _FakeResource: + def __init__(self, loc: str) -> None: + self.location = loc + self.resource_id = "01TEST00000000000000000001" + self.type_name = "fs-directory" + self.resource_kind = "physical" + + return _FakeResource(location) + + +def _run_deterministic() -> None: + """Same content produces same hash twice.""" + with tempfile.TemporaryDirectory() as tmpdir: + fpath = os.path.join(tmpdir, "test.txt") + with open(fpath, "w") as f: + f.write("hello world\n") + + handler = BaseResourceHandler() + res = _make_resource(fpath) + h1 = handler.content_hash(res) # type: ignore[arg-type] + h2 = handler.content_hash(res) # type: ignore[arg-type] + assert h1 == h2, f"Hashes differ: {h1} != {h2}" + assert h1 != EMPTY_CONTENT_HASH, "Got empty hash for non-empty file" + print("deterministic-ok") + + +def _run_empty_sentinel() -> None: + """Missing resource returns EMPTY_CONTENT_HASH.""" + handler = BaseResourceHandler() + res = _make_resource("/nonexistent/path/that/does/not/exist") + h = handler.content_hash(res) # type: ignore[arg-type] + assert h == EMPTY_CONTENT_HASH, f"Expected sentinel, got {h}" + print("empty-sentinel-ok") + + +def _run_content_change_detection() -> None: + """Hash changes when file content changes.""" + with tempfile.TemporaryDirectory() as tmpdir: + fpath = os.path.join(tmpdir, "test.txt") + with open(fpath, "w") as f: + f.write("version 1\n") + + handler = BaseResourceHandler() + res = _make_resource(fpath) + h1 = handler.content_hash(res) # type: ignore[arg-type] + + with open(fpath, "w") as f: + f.write("version 2\n") + + h2 = handler.content_hash(res) # type: ignore[arg-type] + assert h1 != h2, "Hash should change when content changes" + print("content-change-detection-ok") + + +def _run_configurable_algorithm() -> None: + """Algorithm parameter changes the hash output.""" + with tempfile.TemporaryDirectory() as tmpdir: + fpath = os.path.join(tmpdir, "test.txt") + with open(fpath, "w") as f: + f.write("test content\n") + + handler = BaseResourceHandler() + res = _make_resource(fpath) + h_sha256 = handler.content_hash(res, algorithm="sha256") # type: ignore[arg-type] + h_md5 = handler.content_hash(res, algorithm="md5") # type: ignore[arg-type] + assert h_sha256 != h_md5, "Different algorithms should produce different hashes" + assert len(h_sha256) == 64, ( + f"SHA-256 should be 64 hex chars, got {len(h_sha256)}" + ) + assert len(h_md5) == 32, f"MD5 should be 32 hex chars, got {len(h_md5)}" + print("configurable-algorithm-ok") + + +def _run_fs_directory_recursive() -> None: + """FsDirectoryHandler hashes directory contents recursively.""" + with tempfile.TemporaryDirectory() as tmpdir: + os.makedirs(os.path.join(tmpdir, "sub")) + with open(os.path.join(tmpdir, "a.txt"), "w") as f: + f.write("file a\n") + with open(os.path.join(tmpdir, "sub", "b.txt"), "w") as f: + f.write("file b\n") + + handler = FsDirectoryHandler() + res = _make_resource(tmpdir) + h = handler.content_hash(res) # type: ignore[arg-type] + assert h != EMPTY_CONTENT_HASH, "Directory hash should not be empty" + assert len(h) == 64, f"Expected 64 hex chars, got {len(h)}" + print("fs-directory-recursive-ok") + + +_COMMANDS = { + "deterministic": _run_deterministic, + "empty-sentinel": _run_empty_sentinel, + "content-change-detection": _run_content_change_detection, + "configurable-algorithm": _run_configurable_algorithm, + "fs-directory-recursive": _run_fs_directory_recursive, +} + +if __name__ == "__main__": + cmd = sys.argv[1] if len(sys.argv) > 1 else "all" + if cmd == "all": + for fn in _COMMANDS.values(): + fn() + elif cmd in _COMMANDS: + _COMMANDS[cmd]() + else: + print(f"Unknown command: {cmd}", file=sys.stderr) + sys.exit(1) diff --git a/robot/resource_handler_content_hash.robot b/robot/resource_handler_content_hash.robot new file mode 100644 index 00000000..53ee59be --- /dev/null +++ b/robot/resource_handler_content_hash.robot @@ -0,0 +1,41 @@ +*** Settings *** +Documentation Integration tests for ResourceHandler content_hash method +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_resource_handler_content_hash.py + +*** Test Cases *** +Content Hash Is Deterministic + [Documentation] Same file content produces same hash on repeated calls + ${result}= Run Process ${PYTHON} ${HELPER} deterministic cwd=${WORKSPACE} on_timeout=kill timeout=30s + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} deterministic-ok + +Empty Resource Returns Sentinel Hash + [Documentation] Missing resource returns EMPTY_CONTENT_HASH sentinel + ${result}= Run Process ${PYTHON} ${HELPER} empty-sentinel cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} empty-sentinel-ok + +Content Change Detected By Hash + [Documentation] Hash changes when file content is modified + ${result}= Run Process ${PYTHON} ${HELPER} content-change-detection cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} content-change-detection-ok + +Configurable Hash Algorithm + [Documentation] SHA-256 and MD5 produce different-length hashes + ${result}= Run Process ${PYTHON} ${HELPER} configurable-algorithm cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} configurable-algorithm-ok + +FsDirectory Recursive Hash + [Documentation] FsDirectoryHandler hashes directory contents recursively + ${result}= Run Process ${PYTHON} ${HELPER} fs-directory-recursive cwd=${WORKSPACE} on_timeout=kill timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} fs-directory-recursive-ok diff --git a/src/cleveragents/application/services/resource_handler_service.py b/src/cleveragents/application/services/resource_handler_service.py index 56f29839..fc80b32e 100644 --- a/src/cleveragents/application/services/resource_handler_service.py +++ b/src/cleveragents/application/services/resource_handler_service.py @@ -380,3 +380,17 @@ class _DefaultHandler: sandbox_path=sandbox.context.sandbox_path, access=access, ) + + def content_hash( + self, + resource: Resource, + *, + algorithm: str = "sha256", + ) -> str: + """Compute content hash using the base handler default logic.""" + from cleveragents.resource.handlers._base import BaseResourceHandler + + base = BaseResourceHandler() + base._default_strategy = self._type_spec.sandbox_strategy # type: ignore[assignment] + base._type_label = self._type_spec.name # type: ignore[assignment] + return base.content_hash(resource, algorithm=algorithm) diff --git a/src/cleveragents/resource/handlers/_base.py b/src/cleveragents/resource/handlers/_base.py index 2e4c84a6..5941ca18 100644 --- a/src/cleveragents/resource/handlers/_base.py +++ b/src/cleveragents/resource/handlers/_base.py @@ -56,6 +56,10 @@ def _derive_child_id(parent_id: str, child_name: str) -> str: return "".join(reversed(chars)) +#: Sentinel hash returned for empty or missing resources. +EMPTY_CONTENT_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + + class BaseResourceHandler: """Base handler with shared resolve logic and CRUD defaults. @@ -390,3 +394,42 @@ class BaseResourceHandler: scope="project", reason=f"Invalid action: {exc}", ) + + # -- Content Hashing (#836) --------------------------------------------- + + def content_hash( + self, + resource: Resource, + *, + algorithm: str = "sha256", + ) -> str: + """Compute a deterministic content hash. + + Default implementation: if the resource has a ``location`` + pointing to a file, hash that file. If the location points + to a directory, hash the sorted list of filenames (shallow). + Returns :data:`EMPTY_CONTENT_HASH` for missing or empty + resources. + + Subclasses should override for type-specific hashing. + """ + if not resource.location or not os.path.exists(resource.location): + return EMPTY_CONTENT_HASH + + path = resource.location + h = hashlib.new(algorithm) + + if os.path.isfile(path): + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + elif os.path.isdir(path): + # Shallow: hash sorted entry names for change detection + entries = sorted(os.listdir(path)) + for entry in entries: + h.update(entry.encode("utf-8")) + h.update(b"\0") + else: + return EMPTY_CONTENT_HASH + + return h.hexdigest() diff --git a/src/cleveragents/resource/handlers/cloud.py b/src/cleveragents/resource/handlers/cloud.py index cf26d3a1..327e85f6 100644 --- a/src/cleveragents/resource/handlers/cloud.py +++ b/src/cleveragents/resource/handlers/cloud.py @@ -42,6 +42,7 @@ Based on: from __future__ import annotations +import hashlib import logging import os from dataclasses import dataclass, field @@ -49,6 +50,7 @@ from typing import Any from cleveragents.domain.models.core.resource import Resource from cleveragents.infrastructure.sandbox.manager import SandboxManager +from cleveragents.resource.handlers._base import EMPTY_CONTENT_HASH from cleveragents.resource.handlers.protocol import ( Content, DeleteResult, @@ -544,6 +546,29 @@ class CloudResourceHandler: """Not supported for cloud resources.""" raise NotImplementedError("Cloud handler does not support project_access()") + def content_hash( + self, + resource: Resource, + *, + algorithm: str = "sha256", + ) -> str: + """Compute content hash for cloud resources. + + Cloud resources do not have local content. Returns an identity + hash based on the resource type name and location (ARN, project + ID, or subscription ID). + + Returns :data:`EMPTY_CONTENT_HASH` if no location is set. + """ + if not resource.location: + return EMPTY_CONTENT_HASH + + h = hashlib.new(algorithm) + h.update(resource.resource_type_name.encode("utf-8")) + h.update(b"\0") + h.update(resource.location.encode("utf-8")) + return h.hexdigest() + # --------------------------------------------------------------------------- # Stubbed sandbox strategies diff --git a/src/cleveragents/resource/handlers/database.py b/src/cleveragents/resource/handlers/database.py index 3b44adb7..c6c01380 100644 --- a/src/cleveragents/resource/handlers/database.py +++ b/src/cleveragents/resource/handlers/database.py @@ -20,13 +20,14 @@ Based on: from __future__ import annotations +import hashlib import logging import os import sqlite3 from typing import Any -from cleveragents.domain.models.core.resource import SandboxStrategy -from cleveragents.resource.handlers._base import BaseResourceHandler +from cleveragents.domain.models.core.resource import Resource, SandboxStrategy +from cleveragents.resource.handlers._base import EMPTY_CONTENT_HASH, BaseResourceHandler from cleveragents.shared.redaction import mask_database_url, redact_dict logger = logging.getLogger(__name__) @@ -413,3 +414,91 @@ class DatabaseResourceHandler(BaseResourceHandler): _default_strategy = SandboxStrategy.TRANSACTION_ROLLBACK _type_label = "database" + + def content_hash( + self, + resource: Resource, + *, + algorithm: str = "sha256", + ) -> str: + """Compute content hash for database resources. + + Strategy (in priority order): + + 1. **Query result hash** — For SQLite databases, executes + ``SELECT name, sql FROM sqlite_master ORDER BY name`` + and hashes the schema. For PostgreSQL/MySQL, attempts a + schema-level checksum query. + 2. **File hash** — For SQLite with a file path, hashes the + raw database file as fallback. + 3. **Identity hash** — For remote databases where queries + fail, hashes the connection string as a stable identity. + + Returns :data:`EMPTY_CONTENT_HASH` if no location is set. + """ + if not resource.location: + return EMPTY_CONTENT_HASH + + # Strategy 1: query-based hash + query_hash = self._try_query_hash(resource, algorithm) + if query_hash is not None: + return query_hash + + # Strategy 2: file hash (SQLite) + if os.path.isfile(resource.location): + h = hashlib.new(algorithm) + with open(resource.location, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + # Strategy 3: identity hash (remote DB fallback) + h = hashlib.new(algorithm) + h.update(resource.resource_type_name.encode("utf-8")) + h.update(b"\0") + h.update(resource.location.encode("utf-8")) + return h.hexdigest() + + def _try_query_hash( + self, + resource: Resource, + algorithm: str, + ) -> str | None: + """Attempt to hash database content via schema query. + + For SQLite, queries ``sqlite_master`` for the schema. + For other databases, returns ``None`` (query not supported + without an active connection pool). + """ + if not resource.location: + return None + + # SQLite: query the schema + if os.path.isfile(resource.location) and resource.location.endswith( + (".db", ".sqlite", ".sqlite3") + ): + try: + conn = sqlite3.connect( + f"file:{resource.location}?mode=ro", + uri=True, + timeout=5, + ) + try: + cursor = conn.execute( + "SELECT name, sql FROM sqlite_master " + "WHERE type IN ('table', 'index', 'view') " + "ORDER BY name" + ) + h = hashlib.new(algorithm) + for name, sql in cursor: + h.update((name or "").encode("utf-8")) + h.update(b"\0") + h.update((sql or "").encode("utf-8")) + h.update(b"\n") + return h.hexdigest() + finally: + conn.close() + except (sqlite3.Error, OSError): + return None + + return None diff --git a/src/cleveragents/resource/handlers/devcontainer.py b/src/cleveragents/resource/handlers/devcontainer.py index abee18ad..fc2cbfd5 100644 --- a/src/cleveragents/resource/handlers/devcontainer.py +++ b/src/cleveragents/resource/handlers/devcontainer.py @@ -45,7 +45,9 @@ Based on: from __future__ import annotations +import hashlib import logging +import os import subprocess from cleveragents.domain.models.core.container_lifecycle import ( @@ -53,7 +55,10 @@ from cleveragents.domain.models.core.container_lifecycle import ( ) from cleveragents.domain.models.core.resource import Resource, SandboxStrategy from cleveragents.infrastructure.sandbox.manager import SandboxManager -from cleveragents.resource.handlers._base import BaseResourceHandler +from cleveragents.resource.handlers._base import ( + EMPTY_CONTENT_HASH, + BaseResourceHandler, +) from cleveragents.resource.handlers._devcontainer_internals import ( _CONTAINER_ID_PATTERN, _MAX_TERMINAL_TRACKERS, @@ -144,6 +149,138 @@ class DevcontainerHandler(BaseResourceHandler): _default_strategy = SandboxStrategy.NONE _type_label = "devcontainer" + def content_hash( + self, + resource: Resource, + *, + algorithm: str = "sha256", + ) -> str: + """Compute content hash for devcontainer resources. + + Strategy (in priority order): + + 1. **Exec hash** — If a running container ID is available + (from the lifecycle tracker), executes + ``docker exec find /workspaces -type f -exec sha256sum {} +`` + inside the container and hashes the output. + 2. **Config file hash** — Falls back to hashing the + devcontainer JSON config file. Checks + ``.devcontainer/devcontainer.json`` first, then + ``.devcontainer.json`` (first found wins). + 3. Returns :data:`EMPTY_CONTENT_HASH` if neither is available. + """ + if not resource.location: + return EMPTY_CONTENT_HASH + + # Strategy 1: exec hash inside running container + exec_hash = self._try_exec_hash(resource, algorithm) + if exec_hash is not None: + return exec_hash + + # Strategy 2: hash the config file + return self._config_file_hash(resource, algorithm) + + def _try_exec_hash( + self, + resource: Resource, + algorithm: str, + ) -> str | None: + """Attempt to compute hash by execing into the container. + + Uses ``docker ps`` to find a running container whose label + matches the resource location, then execs ``find ... -exec + sha256sum`` inside it. + """ + container_id = self._find_running_container(resource) + if not container_id: + return None + + try: + result = subprocess.run( + [ + "docker", + "exec", + container_id, + "find", + "/workspaces", + "-type", + "f", + "-exec", + "sha256sum", + "{}", + "+", + ], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if result.returncode != 0 or not result.stdout.strip(): + return None + + h = hashlib.new(algorithm) + # Hash the sorted output for determinism + for line in sorted(result.stdout.strip().splitlines()): + h.update(line.encode("utf-8")) + h.update(b"\n") + return h.hexdigest() + except (OSError, subprocess.TimeoutExpired): + return None + + @staticmethod + def _find_running_container(resource: Resource) -> str | None: + """Find a running Docker container for this devcontainer resource. + + Uses ``docker ps`` filtered by the devcontainer label that + matches the resource location. + """ + if not resource.location: + return None + try: + result = subprocess.run( + [ + "docker", + "ps", + "-q", + "--filter", + f"label=devcontainer.local_folder={resource.location}", + ], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if result.returncode != 0 or not result.stdout.strip(): + return None + # Return first matching container ID + return result.stdout.strip().splitlines()[0] + except (OSError, subprocess.TimeoutExpired): + return None + + def _config_file_hash( + self, + resource: Resource, + algorithm: str, + ) -> str: + """Hash the devcontainer config file as fallback.""" + loc = resource.location + if not loc: + return EMPTY_CONTENT_HASH + config_paths = [ + os.path.join(loc, ".devcontainer", "devcontainer.json"), + os.path.join(loc, ".devcontainer.json"), + ] + + h = hashlib.new(algorithm) + for config_path in config_paths: + if os.path.isfile(config_path): + with open(config_path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + return EMPTY_CONTENT_HASH + # -- F16 fix: lazy activation on first resolve ------------------------- _ACTIVATABLE_STATES = frozenset( diff --git a/src/cleveragents/resource/handlers/fs_directory.py b/src/cleveragents/resource/handlers/fs_directory.py index aad1fbd7..f1de5b70 100644 --- a/src/cleveragents/resource/handlers/fs_directory.py +++ b/src/cleveragents/resource/handlers/fs_directory.py @@ -33,7 +33,10 @@ from cleveragents.domain.models.core.resource import ( SandboxStrategy, ) from cleveragents.infrastructure.sandbox.manager import SandboxManager -from cleveragents.resource.handlers._base import BaseResourceHandler +from cleveragents.resource.handlers._base import ( + EMPTY_CONTENT_HASH, + BaseResourceHandler, +) from cleveragents.resource.handlers.protocol import ( CheckpointResult, Content, @@ -379,3 +382,43 @@ class FsDirectoryHandler(BaseResourceHandler): shutil.rmtree(Path(snap).parent, ignore_errors=True) removed += 1 return removed + + # -- Content Hashing (#836) --------------------------------------------- + + def content_hash( + self, + resource: Resource, + *, + algorithm: str = "sha256", + ) -> str: + """Compute a recursive content hash of the directory. + + Walks the directory tree in sorted order, hashing each file's + relative path and content. Only regular files are included + (symlinks and special files are skipped). + """ + if not resource.location or not os.path.isdir(resource.location): + return EMPTY_CONTENT_HASH + + root = resource.location + h = hashlib.new(algorithm) + found_any = False + + for dirpath, dirnames, filenames in os.walk(root): + dirnames.sort() + for fname in sorted(filenames): + full = os.path.join(dirpath, fname) + if not os.path.isfile(full): + continue + rel = os.path.relpath(full, root) + h.update(rel.encode("utf-8")) + h.update(b"\0") + try: + with open(full, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + except OSError: + continue + found_any = True + + return h.hexdigest() if found_any else EMPTY_CONTENT_HASH diff --git a/src/cleveragents/resource/handlers/git_checkout.py b/src/cleveragents/resource/handlers/git_checkout.py index 18f86b3f..297ada78 100644 --- a/src/cleveragents/resource/handlers/git_checkout.py +++ b/src/cleveragents/resource/handlers/git_checkout.py @@ -32,7 +32,10 @@ from cleveragents.domain.models.core.resource import ( SandboxStrategy, ) from cleveragents.infrastructure.sandbox.manager import SandboxManager -from cleveragents.resource.handlers._base import BaseResourceHandler +from cleveragents.resource.handlers._base import ( + EMPTY_CONTENT_HASH, + BaseResourceHandler, +) from cleveragents.resource.handlers.protocol import ( CheckpointResult, Content, @@ -442,3 +445,39 @@ class GitCheckoutHandler(BaseResourceHandler): restored_files=0, message=f"Rolled back to checkpoint '{checkpoint_id}'", ) + + # -- Content Hashing (#836) --------------------------------------------- + + def content_hash( + self, + resource: Resource, + *, + algorithm: str = "sha256", + ) -> str: + """Compute content hash using ``git rev-parse HEAD``. + + For git checkouts, the HEAD commit hash uniquely identifies + the content state. The algorithm parameter is used to + re-hash the commit SHA for consistency with other handlers. + """ + if not resource.location or not os.path.isdir(resource.location): + return EMPTY_CONTENT_HASH + + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=resource.location, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if result.returncode != 0: + return EMPTY_CONTENT_HASH + commit_sha = result.stdout.strip() + # Re-hash through the requested algorithm for consistency + h = hashlib.new(algorithm) + h.update(commit_sha.encode("utf-8")) + return h.hexdigest() + except (OSError, subprocess.TimeoutExpired): + return EMPTY_CONTENT_HASH diff --git a/src/cleveragents/resource/handlers/protocol.py b/src/cleveragents/resource/handlers/protocol.py index 0645171d..c9922a18 100644 --- a/src/cleveragents/resource/handlers/protocol.py +++ b/src/cleveragents/resource/handlers/protocol.py @@ -358,3 +358,26 @@ class ResourceHandler(Protocol): ) -> AccessResult: """Check access permissions for the resource.""" ... + + def content_hash( + self, + resource: Resource, + *, + algorithm: str = "sha256", + ) -> str: + """Compute a deterministic hash of the resource's content. + + The hash is **content-only**: metadata changes (timestamps, + permissions) must not affect the result. Same content must + always produce the same hash. + + Args: + resource: The resource to hash. + algorithm: Hash algorithm name (default ``sha256``). + Must be accepted by :func:`hashlib.new`. + + Returns: + Hex-encoded hash digest, or the sentinel value + ``EMPTY_CONTENT_HASH`` for empty or missing resources. + """ + ...