Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion daiv/sandbox_envs/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from django import forms
from django.utils.translation import gettext_lazy as _

from sandbox_envs.models import _ENV_VAR_NAME_RE, ENV_VARS_MAX_ENTRIES, SandboxEnvironment, Scope
from sandbox_envs.models import _ENV_VAR_NAME_RE, _REPO_ID_RE, ENV_VARS_MAX_ENTRIES, SandboxEnvironment, Scope

logger = logging.getLogger("daiv.sandbox_envs")

Expand Down Expand Up @@ -163,6 +163,14 @@ def clean_repo_ids_json(self):
value = entry.strip()
if not value:
raise forms.ValidationError(_("repo_ids[%d] cannot be blank.") % idx)
if not _REPO_ID_RE.match(value):
raise forms.ValidationError(
_(
"Invalid repo id '%(value)s' at index %(idx)d. Use a slash-separated path "
"like 'owner/repo' or 'group/subgroup/repo'."
)
% {"value": value, "idx": idx}
)
cleaned.append(value)
return cleaned

Expand Down
9 changes: 9 additions & 0 deletions daiv/sandbox_envs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from core.models import EncryptedJSONFieldDescriptor

_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_REPO_ID_RE = re.compile(r"^[^\s/]+(/[^\s/]+)+$")
ENV_VARS_MAX_ENTRIES = 100
ENV_VARS_MAX_ENCRYPTED_SIZE = 32 * 1024 # 32 KiB

Expand Down Expand Up @@ -242,6 +243,14 @@ def _validate_repo_ids(self) -> None:
value = entry.strip()
if not value:
raise ValidationError({"repo_ids": _("repo_ids[%d] cannot be blank.") % idx})
if not _REPO_ID_RE.match(value):
raise ValidationError({
"repo_ids": _(
"Invalid repo id '%(value)s' at index %(idx)d. Use a slash-separated path "
"like 'owner/repo' or 'group/subgroup/repo'."
)
% {"value": value, "idx": idx}
})
if value in seen:
raise ValidationError({"repo_ids": _("Duplicate repo id '%s' in this env.") % value})
seen.add(value)
Expand Down
14 changes: 9 additions & 5 deletions daiv/sandbox_envs/static/sandbox_envs/js/repo-ids-editor.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Alpine component: simple chip list of repo ids (owner/repo) bound to a
* hidden JSON input named `repo_ids_json`.
* Alpine component: simple chip list of repo ids (slash-separated paths,
* e.g. `owner/repo` or `group/subgroup/repo`) bound to a hidden JSON input
* named `repo_ids_json`.
*
* Constructor arg: Array<string> — initial repo ids (already deduped).
*/
Expand All @@ -12,9 +13,12 @@ document.addEventListener("alpine:init", () => {

addDraft() {
const value = this.draft.trim();
if (!value) return;
if (!/^[^\s/]+\/[^\s/]+$/.test(value)) {
this.error = "Use 'owner/repo' format.";
if (!value) {
this.error = "";
return;
}
if (!/^[^\s/]+(\/[^\s/]+)+$/.test(value)) {
this.error = "Use a slash-separated path like 'owner/repo' or 'group/subgroup/repo'.";
return;
}
if (this.ids.includes(value)) {
Expand Down
6 changes: 2 additions & 4 deletions daiv/sandbox_envs/templates/sandbox_envs/list.html
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
{% extends "base_app.html" %}
{% load i18n static %}
{% load i18n %}

{% block title %}{% translate "Sandbox Environments" %} — DAIV{% endblock %}

{% block head_extra %}
<script defer src="{% static 'sandbox_envs/js/env-drawer.js' %}"></script>
<script defer src="{% static 'sandbox_envs/js/env-vars-editor.js' %}"></script>
<script defer src="{% static 'sandbox_envs/js/resource-control.js' %}"></script>
{% include "sandbox_envs/_scripts.html" %}
{% endblock head_extra %}

{% block container_width %}max-w-6xl{% endblock %}
Expand Down
6 changes: 6 additions & 0 deletions tests/unit_tests/sandbox_envs/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,12 @@ def test_repo_ids_invalid_json_rejected(self):
assert not form.is_valid()
assert "repo_ids_json" in form.errors

def test_repo_ids_invalid_format_surfaces_as_field_error(self):
user = User.objects.create(username="u", email="u@x.test")
form = SandboxEnvironmentForm(data=self._post_data(repo_ids_json='["not-a-path"]'), user=user, is_admin=False)
assert not form.is_valid()
assert "repo_ids_json" in form.errors

def test_repo_ids_uniqueness_violation_surfaced_as_form_error(self):
user = User.objects.create(username="u", email="u@x.test")
SandboxEnvironment.objects.create(
Expand Down
20 changes: 20 additions & 0 deletions tests/unit_tests/sandbox_envs/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,26 @@ def test_blank_entry_rejected(self):
env.full_clean()
assert "repo_ids" in exc.value.error_dict

@pytest.mark.parametrize("repo_id", ["acme/foo", "dipcode/omd/omd-theme", "dipcode/omd/wordpress/omd-theme"])
def test_slash_separated_paths_accepted(self, repo_id):
user = self._make_user()
env = SandboxEnvironment(scope=Scope.USER, user=user, name="env", base_image="python:3.14", repo_ids=[repo_id])
env.full_clean()
assert env.repo_ids == [repo_id]

@pytest.mark.parametrize(
"repo_id",
["acme", "acme/", "/acme/foo", "acme//foo", "acme foo/bar", "acme\tfoo/bar", "https://github.com/owner/repo"],
)
def test_invalid_format_rejected(self, repo_id):
from django.core.exceptions import ValidationError

user = self._make_user()
env = SandboxEnvironment(scope=Scope.USER, user=user, name="env", base_image="python:3.14", repo_ids=[repo_id])
with pytest.raises(ValidationError) as exc:
env.full_clean()
assert "repo_ids" in exc.value.error_dict

def test_duplicate_within_same_env_rejected(self):
from django.core.exceptions import ValidationError

Expand Down