From 8c901aa9d79013d550fe48a9b7bf42114293b063 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Tue, 21 Jul 2026 16:58:25 +0500 Subject: [PATCH 1/2] feat: add ownership-aware template updates --- README.md | 3 +- docs/adoption.md | 20 +- docs/memory-bank.md | 12 +- docs/ownership.md | 51 + docs/schema/memory-bank-lock-v1.schema.json | 60 + go.mod | 2 + go.sum | 2 + internal/cli/cli.go | 95 ++ internal/cli/cli_test.go | 108 ++ internal/ownership/classify.go | 34 + internal/ownership/classify_test.go | 37 + internal/ownership/lock.go | 100 ++ internal/ownership/path.go | 102 ++ internal/ownership/plan_regression_test.go | 359 ++++++ internal/ownership/secure_path_unix.go | 154 +++ internal/ownership/secure_path_unix_test.go | 78 ++ internal/ownership/secure_path_windows.go | 262 +++++ internal/ownership/source.go | 155 +++ internal/ownership/source_test.go | 213 ++++ internal/ownership/symlink_test.go | 175 +++ internal/ownership/topology.go | 245 +++++ internal/ownership/transaction_test.go | 530 +++++++++ internal/ownership/types.go | 83 ++ internal/ownership/update.go | 1093 +++++++++++++++++++ internal/ownership/update_test.go | 260 +++++ 25 files changed, 4221 insertions(+), 12 deletions(-) create mode 100644 docs/ownership.md create mode 100644 docs/schema/memory-bank-lock-v1.schema.json create mode 100644 go.sum create mode 100644 internal/ownership/classify.go create mode 100644 internal/ownership/classify_test.go create mode 100644 internal/ownership/lock.go create mode 100644 internal/ownership/path.go create mode 100644 internal/ownership/plan_regression_test.go create mode 100644 internal/ownership/secure_path_unix.go create mode 100644 internal/ownership/secure_path_unix_test.go create mode 100644 internal/ownership/secure_path_windows.go create mode 100644 internal/ownership/source.go create mode 100644 internal/ownership/source_test.go create mode 100644 internal/ownership/symlink_test.go create mode 100644 internal/ownership/topology.go create mode 100644 internal/ownership/transaction_test.go create mode 100644 internal/ownership/types.go create mode 100644 internal/ownership/update.go create mode 100644 internal/ownership/update_test.go diff --git a/README.md b/README.md index 8276d5c..a6fd95f 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ problem space solution space execution space ## Внедрение в проект -В downstream-проект обычно копируется только каталог `memory-bank/`. Исходники CLI, Go-модуль, CI и release-конфигурация этого репозитория не являются частью шаблона приложения. +В downstream-проект устанавливается каталог `memory-bank/` и создаётся ownership lock рядом с ним. Исходники CLI, Go-модуль, CI и release-конфигурация этого репозитория не являются частью шаблона приложения. Инструкция по внедрению охватывает: @@ -76,6 +76,7 @@ problem space solution space execution space | [Внедрение Memory Bank](docs/adoption.md) | Для команд, подключающих шаблон к brownfield- или greenfield-проекту | | [Использование Memory Bank](docs/usage.md) | Для повседневной работы с задачами и AI-агентами после внедрения | | [Установка и использование `memory-bank`](docs/memory-bank.md) | Для пользователей CLI и downstream CI | +| [Ownership и безопасные обновления](docs/ownership.md) | Для понимания lock schema, границ владения и conflict policy | | [Разработка репозитория](docs/development.md) | Для разработчиков шаблона и CLI | `memory-bank lint` проверяет broken links, orphan-документы, достижимость через индексную навигацию и contract ожидаемых `README.md`-индексов. Прежний `memory-bank-lint` временно остаётся совместимым entrypoint для существующей автоматизации. diff --git a/docs/adoption.md b/docs/adoption.md index edae847..74bcd3e 100644 --- a/docs/adoption.md +++ b/docs/adoption.md @@ -1,24 +1,26 @@ # Внедрение Memory Bank в проект -Этот документ описывает, как подключить Memory Bank к существующему или новому проекту. В downstream-проект обычно копируется только каталог `memory-bank/`; dev-инфраструктура этого репозитория (`cmd/`, `.github/`, `.goreleaser.yml`, `go.mod`, `docs/`) не является частью шаблона приложения. +Этот документ описывает, как подключить Memory Bank к существующему или новому проекту. В downstream-проект устанавливается каталог `memory-bank/`, а рядом создаётся `.memory-bank.lock.json`; dev-инфраструктура этого репозитория (`cmd/`, `.github/`, `.goreleaser.yml`, `go.mod`, `docs/`) не является частью шаблона приложения. Lock нужно коммитить: он хранит версию источника и ownership-границу для безопасных обновлений. ## Что копировать -Минимальный переносимый комплект: +Минимальный установленный комплект: ```text memory-bank/ +.memory-bank.lock.json ``` -Для первичной установки на macOS или Linux выполните из корня downstream-проекта: +Установите CLI и подготовьте локальный checkout шаблона на конкретном commit, затем выполните из корня downstream-проекта: ```bash -test ! -e ./memory-bank && - curl -fsSL https://github.com/dapi/memory-bank/archive/refs/heads/main.tar.gz | - tar -xz --strip-components=1 memory-bank-main/memory-bank +memory-bank init \ + --source /path/to/memory-bank-checkout \ + --template-version VERSION \ + --source-ref FULL_COMMIT_SHA ``` -Команда останавливается, если `memory-bank/` уже существует, и не перезаписывает адаптированные документы. На других платформах скачайте source archive этого репозитория и скопируйте из него только каталог `memory-bank/`. +Команда не перезаписывает адаптированные и пользовательские документы. Для уже скопированного template она создаст lock поверх совпадающих файлов. Если managed-файл отличается от выбранного source, сначала разберите conflict. Подробный ownership-контракт и процедура обновления: [`ownership.md`](ownership.md). Опционально добавьте в downstream-проект собственный `AGENTS.md`, `CLAUDE.md` или аналогичный файл с правилом начинать работу с: @@ -35,7 +37,7 @@ memory-bank/dna/README.md Цель brownfield-внедрения — сделать текущий контекст проекта видимым и проверяемым для людей и агентов. Не начинайте с идеального описания будущей архитектуры. Сначала зафиксируйте то, что уже влияет на разработку: реальные пользователи, термины, ограничения, интеграции, принятые решения, неочевидные правила и known gaps. -1. Скопируйте каталог `memory-bank/` в корень проекта. +1. Установите каталог `memory-bank/` и lock через `memory-bank init`. 2. Добавьте в `AGENTS.md`, `CLAUDE.md` или аналогичный файл инструкцию начинать работу с `memory-bank/README.md`. 3. Проведите inventory существующего кода, документации, терминов, архитектурных решений и процессов. 4. Адаптируйте `product/`, `domain/`, `engineering/` и `ops/`. В `engineering/ui-design-guide/` заполните draft-заготовки для реальных UI surfaces и удалите неприменимые файлы вместе со ссылками из index. Не выдумывайте отсутствующие знания: отмечайте пробелы и вопросы явно. @@ -221,7 +223,7 @@ jobs: Memory Bank считается внедрённым, когда: -- `memory-bank/` находится в корне downstream-проекта; +- `memory-bank/` и закоммиченный `.memory-bank.lock.json` находятся в корне downstream-проекта; - постоянный контекст `product/`, `domain/`, `engineering/` и `ops/` отражает фактические правила проекта или явно помечает пробелы; - агентские инструкции указывают читать `memory-bank/README.md` и governance-ядро; - первая реальная задача прошла через выбранный flow или `Small Change` routing record; diff --git a/docs/memory-bank.md b/docs/memory-bank.md index a5a841d..0ded098 100644 --- a/docs/memory-bank.md +++ b/docs/memory-bank.md @@ -1,6 +1,14 @@ # CLI memory-bank -`memory-bank lint` проверяет навигационную целостность `memory-bank/`: +`memory-bank` безопасно устанавливает и обновляет template, а также проверяет навигационную целостность `memory-bank/`. + +Ownership-контракт, классы файлов и atomic update policy описаны в [отдельном документе](ownership.md). Основные команды: + +- `memory-bank init` создаёт `.memory-bank.lock.json` и устанавливает только отсутствующие файлы; +- `memory-bank update` строит ownership-aware mutation plan и применяет его только целиком; +- `memory-bank lint` проверяет документацию. + +`memory-bank lint` обнаруживает: - broken relative markdown links внутри audit scope; - orphan-документы, на которые никто не ссылается внутри scope; @@ -91,7 +99,7 @@ go run github.com/dapi/memory-bank/cmd/memory-bank@latest lint - exit code `0` означает успешную команду без lint errors, `1` — lint errors или operational failure, `2` — неверный вызов CLI; - repo root находится по ближайшему родительскому `.git`, а `--repo-root` переопределяет discovery. -CLI спроектирован для расширения командами `init`, `update`, `doctor` и `adapt brownfield`. Они не реализованы в текущем релизе; в частности, автоматическая brownfield-адаптация не входит в `memory-bank lint`. +`init` и `update` принимают `--source`, `--template-version`, `--source-ref`, `--repo-root`, `--dry-run` и `--json`. Команда `doctor` и автоматическая brownfield-адаптация остаются отдельными будущими возможностями и не входят в `memory-bank lint`. ## Переход с memory-bank-lint diff --git a/docs/ownership.md b/docs/ownership.md new file mode 100644 index 0000000..7749658 --- /dev/null +++ b/docs/ownership.md @@ -0,0 +1,51 @@ +# Ownership и безопасные обновления + +`.memory-bank.lock.json` — repo-local контракт между downstream-проектом и версией шаблона. Файл коммитится вместе с `memory-bank/`. Формальная схема: [`schema/memory-bank-lock-v1.schema.json`](schema/memory-bank-lock-v1.schema.json). + +## Классы владения + +| Класс | Текущая граница шаблона | Поведение update | +| --- | --- | --- | +| `managed` | `memory-bank/dna/`, `flows/`, `prompts/`, а также top-level template-индексы `prd/README.md`, `epics/README.md`, `use-cases/README.md`, `features/README.md`, `adr/README.md` | Проверяет текущий payload по digest. Чистый файл обновляется или удаляется; локальный drift становится conflict. | +| `adapted` | `memory-bank/README.md`, `product/`, `domain/`, `engineering/`, `ops/` | Хранит digest исходной template-base, но не требует совпадения текущего файла. Чистый файл может получить новую base; одновременные upstream и downstream изменения становятся conflict. | +| `user-owned` | Instantiated-документы в `prd/`, `epics/`, `use-cases/`, `features/`, `adr/` и неизвестные downstream paths | Никогда автоматически не перезаписывается и не удаляется. Неизвестный существующий файл получает этот класс по fail-safe правилу. | +| `generated` | `memory-bank/.generated/` зарезервирован для будущих детерминированных генераторов; в текущем template таких файлов нет | Может быть пересоздан или удалён только детерминированным producer. | + +`base_digest` и `base_mode` (`100644` или `100755`) описывают файл в зафиксированной template-base. `payload_digest` и `payload_mode` присутствуют только там, где текущий файл является проверяемым managed/generated contract. Поэтому обычная специализация adapted-документа не считается drift, а изменение executable bit managed-файла проверяется так же, как изменение его содержимого. + +## Init и update + +Команды работают с локальным checkout источника, закреплённым на immutable commit. CLI не делает network fetch и не доверяет moving branch автоматически: + +```bash +memory-bank init \ + --source /path/to/memory-bank-checkout \ + --template-version v1.2.3 \ + --source-ref FULL_COMMIT_SHA +``` + +`--source` должен указывать на корень чистого Git checkout, `--source-ref` — в точности совпадать с его `HEAD`. Незакоммиченные, untracked или ignored payloads внутри `memory-bank/` отклоняются, как и source, совпадающий с downstream repo либо вложенный в него через обычный путь или symlink. Payload и executable modes читаются непосредственно из объектов закреплённого commit, поэтому обычные Git text conversions (`core.autocrlf`, `.gitattributes`) не создают ложный drift и не меняют устанавливаемые байты. + +`init` подходит и для пустого проекта, и для ранее скопированного `memory-bank/`: существующие adapted/user-owned файлы принимаются без перезаписи. Несовпадающий существующий managed-файл останавливает инициализацию как conflict. + +Перед обновлением сначала проверьте полный plan: + +```bash +memory-bank update \ + --source /path/to/new-memory-bank-checkout \ + --template-version v1.3.0 \ + --source-ref FULL_COMMIT_SHA \ + --dry-run +``` + +Добавьте `--json` для machine-readable report format `1`. Каждому известному пути назначается одно решение: `create`, `update`, `preserve`, `conflict` или `delete`. Conflict даёт exit code `1`, сохраняет исходные файлы и lock и требует ручного разрешения. Чтобы принять incoming template, замените конфликтующий файл его incoming payload и повторите update: совпадение digest будет принято как новая base и записано в lock. Для сохранения другого варианта скорректируйте ownership осознанной миграцией lock; команда никогда не выбирает победителя молча. + +Без `--dry-run` сначала строится и проверяется весь plan. При наличии хотя бы одного conflict ничего не применяется. Все новые payload заранее записываются в repo-local temporary staging, существующие файлы перемещаются туда перед заменой, а lock заменяется последним. Planned digests повторно проверяются перед мутацией и перед commit lock. Clean-managed изменения topology `file → directory` и `directory → file` применяются в той же транзакции: прежние payload сначала сохраняются в staging, затем создаётся новая форма пути. Ошибка во время применения откатывает уже сделанные изменения и структуру каталогов без повторной записи содержимого; если rollback или очистка не могут завершиться, команда возвращает явную ошибку и сохраняет staging с recovery-копиями. Успешный no-op не переписывает lock, поэтому повторный update идемпотентен. + +Корень downstream-репозитория закрепляется по filesystem identity на весь run. Destination-мутации выполняются относительно уже открытых directory handles: через `openat`-семейство на Unix и через handle-relative NT APIs на Windows. Поэтому замена ранее проверенного parent на symlink или junction не может перенаправить операцию наружу. Symlink или reparse point в любом компоненте ниже корня, включая сам managed-файл или lock, считается unsafe path: команда завершается ошибкой и не читает и не изменяет target ссылки. + +## Версионирование + +`schema_version` версионирует lock contract независимо от версии template. CLI читает schema `1`; неизвестная версия завершается ошибкой без мутаций. Unversioned prototype со значением `0` имеет семантику v1 и атомарно переписывается в schema `1` при следующем успешном update. + +`template.version` — понятная человеку версия, `template.source_ref` — immutable идентификатор фактического source checkout. `last_update` меняется только вместе с успешной сменой template state или миграцией schema. diff --git a/docs/schema/memory-bank-lock-v1.schema.json b/docs/schema/memory-bank-lock-v1.schema.json new file mode 100644 index 0000000..0851c75 --- /dev/null +++ b/docs/schema/memory-bank-lock-v1.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/dapi/memory-bank/blob/main/docs/schema/memory-bank-lock-v1.schema.json", + "title": "Memory Bank ownership lock v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "template", "last_update", "files"], + "properties": { + "schema_version": { "const": 1 }, + "template": { + "type": "object", + "additionalProperties": false, + "required": ["version", "source_ref"], + "properties": { + "version": { "type": "string", "minLength": 1 }, + "source_ref": { "type": "string", "pattern": "^[0-9a-fA-F]{40}([0-9a-fA-F]{24})?$" } + } + }, + "last_update": { + "type": "object", + "additionalProperties": false, + "required": ["version", "at"], + "properties": { + "version": { "type": "string", "minLength": 1 }, + "at": { "type": "string", "format": "date-time" } + } + }, + "files": { + "type": "object", + "propertyNames": { "pattern": "^memory-bank/(?!\\.\\.(?:/|$))(?!.*\\/\\.\\.(?:/|$))[^\\\\]+$" }, + "additionalProperties": { "$ref": "#/$defs/file" } + } + }, + "$defs": { + "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "file": { + "type": "object", + "additionalProperties": false, + "required": ["ownership"], + "properties": { + "ownership": { "enum": ["managed", "adapted", "user-owned", "generated"] }, + "base_digest": { "$ref": "#/$defs/digest" }, + "payload_digest": { "$ref": "#/$defs/digest" }, + "base_mode": { "$ref": "#/$defs/mode" }, + "payload_mode": { "$ref": "#/$defs/mode" } + }, + "allOf": [ + { + "if": { "properties": { "ownership": { "enum": ["managed", "generated"] } } }, + "then": { "required": ["base_digest", "payload_digest", "base_mode", "payload_mode"] } + }, + { + "if": { "properties": { "ownership": { "const": "adapted" } } }, + "then": { "required": ["base_digest", "base_mode"] } + } + ] + }, + "mode": { "enum": ["100644", "100755"] } + } +} diff --git a/go.mod b/go.mod index 1654891..fa7dabf 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/dapi/memory-bank go 1.21 + +require golang.org/x/sys v0.17.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..735d9a7 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 412841a..ab1c2b9 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -7,8 +7,11 @@ import ( "flag" "fmt" "io" + "path/filepath" + "sort" "github.com/dapi/memory-bank/internal/lint" + "github.com/dapi/memory-bank/internal/ownership" "github.com/dapi/memory-bank/internal/repository" ) @@ -41,6 +44,10 @@ func Run(arguments []string, version string, stdout, stderr io.Writer) int { switch arguments[0] { case "lint": return RunLint(arguments[1:], "memory-bank lint", version, stdout, stderr) + case "init": + return runOwnership(arguments[1:], "init", stdout, stderr) + case "update": + return runOwnership(arguments[1:], "update", stdout, stderr) case "--version", "-version": if len(arguments) != 1 { fmt.Fprintf(stderr, "memory-bank: unexpected arguments: %v\n", arguments[1:]) @@ -68,6 +75,8 @@ func printRootUsage(writer io.Writer) { fmt.Fprintln(writer, "Usage: memory-bank [options]") fmt.Fprintln(writer) fmt.Fprintln(writer, "Commands:") + fmt.Fprintln(writer, " init Adopt or install a template and create its ownership lock") + fmt.Fprintln(writer, " update Safely update a template using its ownership lock") fmt.Fprintln(writer, " lint Audit markdown navigation integrity") fmt.Fprintln(writer) fmt.Fprintln(writer, "Options:") @@ -75,6 +84,92 @@ func printRootUsage(writer io.Writer) { fmt.Fprintln(writer, " --version Print the version and exit") } +func runOwnership(arguments []string, command string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("memory-bank "+command, flag.ContinueOnError) + flags.SetOutput(stderr) + flags.Usage = func() { + fmt.Fprintf(stderr, "Usage: memory-bank %s --source DIR --template-version VERSION --source-ref REF [options]\n", command) + flags.PrintDefaults() + } + repoRootArgument := addRepoRootFlag(flags) + sourceRootArgument := flags.String("source", "", "clean template Git checkout containing memory-bank/") + templateVersion := flags.String("template-version", "", "human-readable template version") + sourceRef := flags.String("source-ref", "", "full commit SHA matching the source checkout HEAD") + dryRun := flags.Bool("dry-run", false, "print the complete mutation plan without applying it") + jsonOutput := addJSONOutputFlag(flags) + if err := flags.Parse(arguments); err != nil { + if errors.Is(err, flag.ErrHelp) { + return exitSuccess + } + return exitUsage + } + if flags.NArg() > 0 { + fmt.Fprintf(stderr, "memory-bank %s: unexpected arguments: %v\n", command, flags.Args()) + return exitUsage + } + if *sourceRootArgument == "" || *templateVersion == "" || *sourceRef == "" { + fmt.Fprintf(stderr, "memory-bank %s: --source, --template-version, and --source-ref are required\n", command) + return exitUsage + } + repoRoot, err := repository.ResolveRoot(*repoRootArgument) + if err != nil { + fmt.Fprintln(stderr, err) + return exitFailure + } + sourceRoot, err := filepath.Abs(*sourceRootArgument) + if err != nil { + fmt.Fprintln(stderr, err) + return exitFailure + } + options := ownership.Options{ + RepoRoot: repoRoot, SourceRoot: sourceRoot, TemplateVersion: *templateVersion, + SourceRef: *sourceRef, DryRun: *dryRun, + } + var report ownership.Report + if command == "init" { + report, err = ownership.Init(options) + } else { + report, err = ownership.Update(options) + } + if err != nil { + if report.Applied { + if outputErr := writeResult(stdout, *jsonOutput, report, func(writer io.Writer) { + printOwnershipReport(writer, report) + }); outputErr != nil { + fmt.Fprintln(stderr, outputErr) + } + } + fmt.Fprintln(stderr, err) + return exitFailure + } + if err := writeResult(stdout, *jsonOutput, report, func(writer io.Writer) { + printOwnershipReport(writer, report) + }); err != nil { + fmt.Fprintln(stderr, err) + return exitFailure + } + if report.ConflictCount > 0 { + return exitFailure + } + return exitSuccess +} + +func printOwnershipReport(writer io.Writer, report ownership.Report) { + decisions := append([]ownership.Decision(nil), report.Decisions...) + sort.Slice(decisions, func(i, j int) bool { return decisions[i].Path < decisions[j].Path }) + for _, decision := range decisions { + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", decision.Action, decision.Ownership, decision.Path, decision.Reason) + } + switch { + case report.ConflictCount > 0: + fmt.Fprintf(writer, "update not applied: %d conflict(s)\n", report.ConflictCount) + case report.DryRun: + fmt.Fprintln(writer, "dry run: no files changed") + case report.Applied: + fmt.Fprintln(writer, "update applied atomically") + } +} + // RunLint executes the lint command. commandName controls only human-readable // usage and diagnostics, allowing the compatibility binary to preserve its CLI. func RunLint(arguments []string, commandName, version string, stdout, stderr io.Writer) int { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 6236d23..dc41b7d 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -3,6 +3,8 @@ package cli import ( "bytes" "encoding/json" + "os" + "os/exec" "path/filepath" "strings" "testing" @@ -19,6 +21,26 @@ func testRepository(t *testing.T) string { return root } +func commitCLISource(t *testing.T, root, message string) string { + t.Helper() + if _, err := os.Stat(filepath.Join(root, ".git")); os.IsNotExist(err) { + runCLIGit(t, root, "init", "--quiet") + } + runCLIGit(t, root, "add", "--all") + runCLIGit(t, root, "-c", "user.name=Memory Bank Tests", "-c", "user.email=tests@example.invalid", "commit", "--quiet", "-m", message) + return runCLIGit(t, root, "rev-parse", "HEAD") +} + +func runCLIGit(t *testing.T, root string, arguments ...string) string { + t.Helper() + command := exec.Command("git", append([]string{"-C", root}, arguments...)...) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", arguments, err, output) + } + return strings.TrimSpace(string(output)) +} + func TestPrimaryAndCompatibilityEntrypointsHaveLintParity(t *testing.T) { arguments := []string{"--repo-root", testRepository(t), "--max-depth", "1", "--json"} var primaryStdout, primaryStderr bytes.Buffer @@ -97,3 +119,89 @@ func TestCompatibilityHelpAndVersion(t *testing.T) { t.Fatalf("unexpected version: %q", stdout.String()) } } + +func TestOwnershipDryRunJSONReportsPlanWithoutMutation(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + if err := os.MkdirAll(filepath.Join(source, "memory-bank", "dna"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "memory-bank", "dna", "rule.md"), []byte("rule\n"), 0o644); err != nil { + t.Fatal(err) + } + sourceRef := commitCLISource(t, source, "initial source") + arguments := []string{"init", "--repo-root", repo, "--source", source, "--template-version", "v1", "--source-ref", sourceRef, "--dry-run", "--json"} + var stdout, stderr bytes.Buffer + if exitCode := Run(arguments, "test", &stdout, &stderr); exitCode != 0 { + t.Fatalf("unexpected exit %d: %s", exitCode, stderr.String()) + } + var report struct { + FormatVersion int `json:"format_version"` + DryRun bool `json:"dry_run"` + Decisions []struct { + Action string `json:"action"` + } `json:"decisions"` + } + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("invalid report: %v\n%s", err, stdout.String()) + } + if report.FormatVersion != 1 || !report.DryRun || len(report.Decisions) != 1 || report.Decisions[0].Action != "create" { + t.Fatalf("unexpected report: %#v", report) + } + if _, err := os.Stat(filepath.Join(repo, "memory-bank", "dna", "rule.md")); !os.IsNotExist(err) { + t.Fatalf("dry-run mutated repository: %v", err) + } +} + +func TestOwnershipDryRunJSONReportsCollisionAsUserOwned(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + dna := filepath.Join(source, "memory-bank", "dna") + if err := os.MkdirAll(dna, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dna, "seed.md"), []byte("seed\n"), 0o644); err != nil { + t.Fatal(err) + } + initialRef := commitCLISource(t, source, "initial source") + baseArguments := []string{"--repo-root", repo, "--source", source} + initArguments := append([]string{"init"}, baseArguments...) + initArguments = append(initArguments, "--template-version", "v1", "--source-ref", initialRef) + var stdout, stderr bytes.Buffer + if exitCode := Run(initArguments, "test", &stdout, &stderr); exitCode != 0 { + t.Fatalf("unexpected init exit %d: %s", exitCode, stderr.String()) + } + + collision := filepath.Join("memory-bank", "dna", "collision.md") + if err := os.WriteFile(filepath.Join(repo, collision), []byte("downstream\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, collision), []byte("upstream\n"), 0o644); err != nil { + t.Fatal(err) + } + updatedRef := commitCLISource(t, source, "add collision") + stdout.Reset() + stderr.Reset() + updateArguments := append([]string{"update"}, baseArguments...) + updateArguments = append(updateArguments, "--template-version", "v2", "--source-ref", updatedRef, "--dry-run", "--json") + if exitCode := Run(updateArguments, "test", &stdout, &stderr); exitCode != 0 { + t.Fatalf("unexpected update exit %d: %s", exitCode, stderr.String()) + } + var report struct { + Decisions []struct { + Path string `json:"path"` + Ownership string `json:"ownership"` + Action string `json:"action"` + } `json:"decisions"` + } + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("invalid report: %v\n%s", err, stdout.String()) + } + for _, decision := range report.Decisions { + if decision.Path == filepath.ToSlash(collision) { + if decision.Action != "preserve" || decision.Ownership != "user-owned" { + t.Fatalf("collision decision disagrees with persisted ownership: %#v", decision) + } + return + } + } + t.Fatalf("collision decision missing from report: %#v", report.Decisions) +} diff --git a/internal/ownership/classify.go b/internal/ownership/classify.go new file mode 100644 index 0000000..9a5bb81 --- /dev/null +++ b/internal/ownership/classify.go @@ -0,0 +1,34 @@ +package ownership + +import "strings" + +// Classify defines the template boundary. Paths must be repository-relative. +func Classify(path string) Class { + if strings.HasPrefix(path, "memory-bank/.generated/") { + return Generated + } + if path == "memory-bank/README.md" { + return Adapted + } + for _, prefix := range []string{"memory-bank/dna/", "memory-bank/flows/", "memory-bank/prompts/"} { + if strings.HasPrefix(path, prefix) { + return Managed + } + } + for _, prefix := range []string{"memory-bank/product/", "memory-bank/domain/", "memory-bank/engineering/", "memory-bank/ops/"} { + if strings.HasPrefix(path, prefix) { + return Adapted + } + } + for _, prefix := range []string{"memory-bank/prd/", "memory-bank/epics/", "memory-bank/use-cases/", "memory-bank/features/", "memory-bank/adr/"} { + if strings.HasPrefix(path, prefix) { + if path == prefix+"README.md" { + return Managed + } + return UserOwned + } + } + // Unknown files below memory-bank are downstream-owned by default. This is + // deliberately fail-safe for directories introduced by a project. + return UserOwned +} diff --git a/internal/ownership/classify_test.go b/internal/ownership/classify_test.go new file mode 100644 index 0000000..1871194 --- /dev/null +++ b/internal/ownership/classify_test.go @@ -0,0 +1,37 @@ +package ownership + +import "testing" + +func TestCurrentTemplateBoundary(t *testing.T) { + tests := map[string]Class{ + "memory-bank/dna/governance.md": Managed, + "memory-bank/flows/templates/brief.md": Managed, + "memory-bank/prompts/PROMPT-001.md": Managed, + "memory-bank/domain/model.md": Adapted, + "memory-bank/engineering/architecture.md": Adapted, + "memory-bank/README.md": Adapted, + "memory-bank/features/README.md": Managed, + "memory-bank/features/FT-001/brief.md": UserOwned, + "memory-bank/features/FT-001/README.md": UserOwned, + "memory-bank/custom/project-note.md": UserOwned, + "memory-bank/.generated/index.json": Generated, + } + for path, want := range tests { + if got := Classify(path); got != want { + t.Errorf("Classify(%q) = %q, want %q", path, got, want) + } + } +} + +func TestOnlyTopLevelArtifactIndexesAreManaged(t *testing.T) { + for _, directory := range []string{"prd", "epics", "use-cases", "features", "adr"} { + t.Run(directory, func(t *testing.T) { + if got := Classify("memory-bank/" + directory + "/README.md"); got != Managed { + t.Fatalf("top-level index classified as %q, want %q", got, Managed) + } + if got := Classify("memory-bank/" + directory + "/instance/README.md"); got != UserOwned { + t.Fatalf("nested artifact README classified as %q, want %q", got, UserOwned) + } + }) + } +} diff --git a/internal/ownership/lock.go b/internal/ownership/lock.go new file mode 100644 index 0000000..814f291 --- /dev/null +++ b/internal/ownership/lock.go @@ -0,0 +1,100 @@ +package ownership + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "regexp" + "strings" +) + +var ErrLockNotFound = errors.New("memory-bank lock not found") +var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) +var modePattern = regexp.MustCompile(`^100(644|755)$`) + +func ReadLock(repoRoot string) (Lock, bool, error) { + repo, err := pinRepoRoot(repoRoot) + if err != nil { + return Lock{}, false, err + } + lock, exists, _, err := readLockSnapshot(repo) + return lock, exists, err +} + +func readLockSnapshot(repo pinnedRepo) (Lock, bool, string, error) { + _, destinationInfo, exists, err := inspectDestination(repo, LockFileName) + if err != nil { + return Lock{}, false, "", err + } + if !exists { + return Lock{}, false, "", nil + } + readInfo, data, err := secureReadDestination(repo, LockFileName) + if err != nil { + return Lock{}, false, "", err + } + if !os.SameFile(destinationInfo, readInfo) { + return Lock{}, false, "", fmt.Errorf("%s changed while reading", LockFileName) + } + lockDigest := digest(data) + var lock Lock + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&lock); err != nil { + return Lock{}, false, "", fmt.Errorf("read %s: %w", LockFileName, err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return Lock{}, false, "", fmt.Errorf("read %s: trailing JSON content", LockFileName) + } + // Schema 0 was the unversioned prototype. Its fields have v1 semantics and + // are rewritten as v1 after the next successful update. + if lock.SchemaVersion != 0 && lock.SchemaVersion != CurrentSchemaVersion { + return Lock{}, false, "", fmt.Errorf("unsupported memory-bank lock schema %d (supported: %d)", lock.SchemaVersion, CurrentSchemaVersion) + } + if lock.Files == nil { + lock.Files = make(map[string]File) + } + if lock.Template.Version == "" || !immutableRefPattern.MatchString(lock.Template.SourceRef) { + return Lock{}, false, "", fmt.Errorf("invalid template identity in %s", LockFileName) + } + if lock.LastUpdate.Version == "" || lock.LastUpdate.At.IsZero() { + return Lock{}, false, "", fmt.Errorf("invalid last update in %s", LockFileName) + } + for filePath, file := range lock.Files { + if strings.Contains(filePath, "\\") || path.Clean(filePath) != filePath || !strings.HasPrefix(filePath, "memory-bank/") { + return Lock{}, false, "", fmt.Errorf("invalid path %q in %s", filePath, LockFileName) + } + switch file.Ownership { + case Managed, Generated: + if !digestPattern.MatchString(file.BaseDigest) || !digestPattern.MatchString(file.PayloadDigest) { + return Lock{}, false, "", fmt.Errorf("invalid digest contract for %s", filePath) + } + if lock.SchemaVersion == CurrentSchemaVersion && (!modePattern.MatchString(file.BaseMode) || !modePattern.MatchString(file.PayloadMode)) { + return Lock{}, false, "", fmt.Errorf("invalid mode contract for %s", filePath) + } + case Adapted: + if !digestPattern.MatchString(file.BaseDigest) { + return Lock{}, false, "", fmt.Errorf("invalid base digest for %s", filePath) + } + if lock.SchemaVersion == CurrentSchemaVersion && !modePattern.MatchString(file.BaseMode) { + return Lock{}, false, "", fmt.Errorf("invalid base mode for %s", filePath) + } + case UserOwned: + default: + return Lock{}, false, "", fmt.Errorf("invalid ownership %q for %s", file.Ownership, filePath) + } + } + return lock, true, lockDigest, nil +} + +func marshalLock(lock Lock) ([]byte, error) { + data, err := json.MarshalIndent(lock, "", " ") + if err != nil { + return nil, err + } + return append(data, '\n'), nil +} diff --git a/internal/ownership/path.go b/internal/ownership/path.go new file mode 100644 index 0000000..3bbb69f --- /dev/null +++ b/internal/ownership/path.go @@ -0,0 +1,102 @@ +package ownership + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +type pinnedRepo struct { + root string + info fs.FileInfo +} + +func pinRepoRoot(root string) (pinnedRepo, error) { + if root == "" { + return pinnedRepo{}, errors.New("repo root is required") + } + absRoot, err := filepath.Abs(root) + if err != nil { + return pinnedRepo{}, fmt.Errorf("resolve repo root: %w", err) + } + resolvedRoot, err := filepath.EvalSymlinks(absRoot) + if err != nil { + return pinnedRepo{}, fmt.Errorf("resolve repo root: %w", err) + } + info, err := inspectRepoRoot(resolvedRoot, nil) + if err != nil { + return pinnedRepo{}, err + } + return pinnedRepo{root: resolvedRoot, info: info}, nil +} + +func inspectRepoRoot(root string, expected fs.FileInfo) (fs.FileInfo, error) { + info, err := os.Lstat(root) + if err != nil { + return nil, fmt.Errorf("inspect repo root: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("unsafe repo root: %s is a symlink", root) + } + if !info.IsDir() { + return nil, fmt.Errorf("repo root is not a directory: %s", root) + } + if expected != nil && !os.SameFile(expected, info) { + return nil, fmt.Errorf("unsafe repo root: %s changed during update", root) + } + return info, nil +} + +// destinationPath resolves a repository-relative path without following any +// symlink below repoRoot. A missing suffix is allowed so callers can safely +// plan creation of a new file. +func destinationPathPinned(repo pinnedRepo, relative string) (string, error) { + target, osRelative, err := destinationPathLexicalPinned(repo, relative) + if err != nil { + return "", err + } + + current := repo.root + components := strings.Split(osRelative, string(filepath.Separator)) + for index, component := range components { + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + return target, nil + } + if err != nil { + return "", fmt.Errorf("inspect destination path %q: %w", relative, err) + } + componentPath, relErr := filepath.Rel(repo.root, current) + if relErr != nil { + componentPath = current + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("unsafe destination path %q: component %q is a symlink", relative, filepath.ToSlash(componentPath)) + } + if index < len(components)-1 && !info.IsDir() { + return "", fmt.Errorf("unsafe destination path %q: component %q is not a directory", relative, filepath.ToSlash(componentPath)) + } + } + return target, nil +} + +// destinationPathLexicalPinned validates a repository-relative path without +// requiring its current ancestors to be directories. Topology transitions use +// it while a clean managed ancestor is still waiting to be removed. +func destinationPathLexicalPinned(repo pinnedRepo, relative string) (string, string, error) { + if _, err := inspectRepoRoot(repo.root, repo.info); err != nil { + return "", "", err + } + if relative == "" || strings.Contains(relative, "\\") { + return "", "", fmt.Errorf("unsafe destination path %q", relative) + } + osRelative := filepath.FromSlash(relative) + if !filepath.IsLocal(osRelative) || filepath.ToSlash(filepath.Clean(osRelative)) != relative { + return "", "", fmt.Errorf("unsafe destination path %q", relative) + } + return filepath.Join(repo.root, osRelative), osRelative, nil +} diff --git a/internal/ownership/plan_regression_test.go b/internal/ownership/plan_regression_test.go new file mode 100644 index 0000000..9ec3bb1 --- /dev/null +++ b/internal/ownership/plan_regression_test.go @@ -0,0 +1,359 @@ +package ownership + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDeletedManagedFileIsDownstreamDrift(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/dna/rule.md" + write(t, source, path, "managed\n") + initialize(t, repo, source) + lockBefore := read(t, repo, LockFileName) + if err := os.Remove(filepath.Join(repo, filepath.FromSlash(path))); err != nil { + t.Fatal(err) + } + + report, err := Update(opts(repo, source, "b")) + decision := decisionFor(t, report, path) + if err != nil || report.Applied || report.ConflictCount != 1 || decision.Action != Conflict || decision.Reason != "managed file has downstream drift" { + t.Fatalf("deleted managed file was not treated as drift: report=%#v err=%v", report, err) + } + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(path))); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("deleted managed file was restored: %v", err) + } + if lockAfter := read(t, repo, LockFileName); lockAfter != lockBefore { + t.Fatal("conflicting managed deletion changed the lock") + } +} + +func TestDeletedGeneratedFileIsRegenerated(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/.generated/index.json" + write(t, source, path, "{\"generated\":true}\n") + initialize(t, repo, source) + if err := os.Remove(filepath.Join(repo, filepath.FromSlash(path))); err != nil { + t.Fatal(err) + } + + report, err := Update(opts(repo, source, "b")) + decision := decisionFor(t, report, path) + if err != nil || !report.Applied || report.ConflictCount != 0 || decision.Action != UpdateFile || decision.Ownership != Generated { + t.Fatalf("deleted generated file was not regenerated: report=%#v err=%v", report, err) + } + if got := read(t, repo, path); got != "{\"generated\":true}\n" { + t.Fatalf("unexpected regenerated payload: %q", got) + } +} + +func TestInitRegeneratesExistingGeneratedFile(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/.generated/index.json" + write(t, repo, path, "stale\n") + write(t, source, path, "generated\n") + + report, err := Init(opts(repo, source, "a")) + decision := decisionFor(t, report, path) + if err != nil || !report.Applied || decision.Action != UpdateFile || decision.Ownership != Generated { + t.Fatalf("init did not regenerate deterministic payload: report=%#v err=%v", report, err) + } + if got := read(t, repo, path); got != "generated\n" { + t.Fatalf("unexpected initialized generated payload: %q", got) + } +} + +func TestCollisionReportsAndPersistsUserOwnership(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + seed := "memory-bank/dna/seed.md" + path := "memory-bank/dna/collision.md" + write(t, source, seed, "seed\n") + initialize(t, repo, source) + write(t, repo, path, "downstream\n") + write(t, source, path, "upstream\n") + lockBefore := read(t, repo, LockFileName) + + dryRunOptions := opts(repo, source, "b") + dryRunOptions.DryRun = true + report, err := Update(dryRunOptions) + decision := decisionFor(t, report, path) + if err != nil || report.Applied || !report.DryRun || report.ConflictCount != 0 || decision.Action != Preserve || decision.Ownership != UserOwned { + t.Fatalf("dry-run reported the wrong collision ownership: report=%#v err=%v", report, err) + } + if got := read(t, repo, path); got != "downstream\n" { + t.Fatalf("dry-run overwrote the collision: %q", got) + } + if lockAfter := read(t, repo, LockFileName); lockAfter != lockBefore { + t.Fatal("dry-run changed the lock") + } + + report, err = Update(opts(repo, source, "b")) + decision = decisionFor(t, report, path) + if err != nil || !report.Applied || report.ConflictCount != 0 || decision.Action != Preserve || decision.Ownership != UserOwned { + t.Fatalf("update reported the wrong collision ownership: report=%#v err=%v", report, err) + } + if got := read(t, repo, path); got != "downstream\n" { + t.Fatalf("update overwrote the collision: %q", got) + } + lock, exists, err := ReadLock(repo) + if err != nil || !exists { + t.Fatalf("could not read updated lock: exists=%v err=%v", exists, err) + } + if got := lock.Files[path]; got != (File{Ownership: UserOwned}) { + t.Fatalf("collision ownership was not persisted: %#v", got) + } + + report, err = Update(opts(repo, source, "b")) + decision = decisionFor(t, report, path) + if err != nil || report.Applied || decision.Action != Preserve || decision.Ownership != UserOwned { + t.Fatalf("repeated update reported the wrong collision ownership: report=%#v err=%v", report, err) + } +} + +func TestManagedEditAfterPlanningIsNotOverwritten(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/dna/rule.md" + write(t, source, path, "one\n") + initialize(t, repo, source) + write(t, source, path, "two\n") + lockBefore := read(t, repo, LockFileName) + + options := opts(repo, source, "b") + options.Now = func() time.Time { + write(t, repo, path, "late drift\n") + return fixedTime + } + if _, err := Update(options); err == nil || !strings.Contains(err.Error(), "content changed") { + t.Fatalf("expected late-drift error, got %v", err) + } + if got := read(t, repo, path); got != "late drift\n" { + t.Fatalf("late downstream edit was overwritten: %q", got) + } + if got := read(t, repo, LockFileName); got != lockBefore { + t.Fatal("late downstream edit changed the lock") + } +} + +func TestManagedEditAfterStagingIsNotOverwritten(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/dna/rule.md" + write(t, source, path, "one\n") + initialize(t, repo, source) + write(t, source, path, "two\n") + lockBefore := read(t, repo, LockFileName) + + options := opts(repo, source, "b") + options.BeforeMutation = func(decision Decision) error { + if decision.Path == path { + write(t, repo, path, "late drift\n") + } + return nil + } + if _, err := Update(options); err == nil || !strings.Contains(err.Error(), "content changed") { + t.Fatalf("expected late-drift error, got %v", err) + } + if got := read(t, repo, path); got != "late drift\n" { + t.Fatalf("late downstream edit was overwritten: %q", got) + } + if got := read(t, repo, LockFileName); got != lockBefore { + t.Fatal("late downstream edit changed the lock") + } +} + +func TestPreservedManagedEditBeforeLockCommitIsRejected(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/dna/rule.md" + write(t, source, path, "managed\n") + initialize(t, repo, source) + lockBefore := read(t, repo, LockFileName) + + options := opts(repo, source, "b") + options.Now = func() time.Time { + write(t, repo, path, "late drift\n") + return fixedTime + } + if _, err := Update(options); err == nil || !strings.Contains(err.Error(), "before lock commit") { + t.Fatalf("expected lock-precondition error, got %v", err) + } + if got := read(t, repo, path); got != "late drift\n" { + t.Fatalf("late downstream edit was overwritten: %q", got) + } + if got := read(t, repo, LockFileName); got != lockBefore { + t.Fatal("lock was committed after a preserved managed file drifted") + } +} + +func TestIncomingPayloadResolvesManagedAndAdaptedConflicts(t *testing.T) { + for _, test := range []struct { + name string + path string + ownership Class + }{ + {name: "managed", path: "memory-bank/dna/rule.md", ownership: Managed}, + {name: "adapted", path: "memory-bank/domain/model.md", ownership: Adapted}, + } { + t.Run(test.name, func(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + write(t, source, test.path, "base\n") + initialize(t, repo, source) + write(t, repo, test.path, "downstream\n") + write(t, source, test.path, "incoming\n") + + report, err := Update(opts(repo, source, "b")) + if err != nil || report.ConflictCount != 1 || decisionFor(t, report, test.path).Action != Conflict { + t.Fatalf("expected initial conflict: report=%#v err=%v", report, err) + } + + write(t, repo, test.path, "incoming\n") + report, err = Update(opts(repo, source, "b")) + decision := decisionFor(t, report, test.path) + if err != nil || !report.Applied || report.ConflictCount != 0 || decision.Action != Preserve || !strings.Contains(decision.Reason, "matches incoming") { + t.Fatalf("incoming resolution was not accepted: report=%#v err=%v", report, err) + } + lock, exists, err := ReadLock(repo) + if err != nil || !exists { + t.Fatalf("could not read resolved lock: exists=%v err=%v", exists, err) + } + want := File{Ownership: test.ownership, BaseDigest: digest([]byte("incoming\n")), BaseMode: "100644"} + if test.ownership == Managed { + want.PayloadDigest = want.BaseDigest + want.PayloadMode = want.BaseMode + } + if got := lock.Files[test.path]; got != want { + t.Fatalf("resolved lock did not advance to incoming base: got=%#v want=%#v", got, want) + } + + report, err = Update(opts(repo, source, "b")) + if err != nil || report.Applied || report.ConflictCount != 0 { + t.Fatalf("resolved update was not idempotent: report=%#v err=%v", report, err) + } + }) + } +} + +func TestNestedArtifactREADMEStaysUserOwned(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/features/FT-001/README.md" + write(t, source, path, "seed\n") + initialize(t, repo, source) + write(t, repo, path, "project package\n") + write(t, source, path, "changed seed\n") + + report, err := Update(opts(repo, source, "b")) + decision := decisionFor(t, report, path) + if err != nil || !report.Applied || decision.Action != Preserve || decision.Ownership != UserOwned { + t.Fatalf("nested README was not preserved as user-owned: report=%#v err=%v", report, err) + } + if got := read(t, repo, path); got != "project package\n" { + t.Fatalf("nested README was overwritten: %q", got) + } + lock, exists, err := ReadLock(repo) + if err != nil || !exists || lock.Files[path].Ownership != UserOwned { + t.Fatalf("nested README lock ownership is wrong: lock=%#v exists=%v err=%v", lock.Files[path], exists, err) + } + + if err := os.Remove(filepath.Join(source, filepath.FromSlash(path))); err != nil { + t.Fatal(err) + } + report, err = Update(opts(repo, source, "c")) + if err != nil || !report.Applied || decisionFor(t, report, path).Action != Preserve { + t.Fatalf("nested README was not preserved after upstream removal: report=%#v err=%v", report, err) + } + if got := read(t, repo, path); got != "project package\n" { + t.Fatalf("nested README was deleted after upstream removal: %q", got) + } +} + +func TestCleanManagedPathTopologyTransitions(t *testing.T) { + for _, test := range []struct { + name string + initialPath string + initialData string + updatedPath string + updatedData string + }{ + { + name: "file to directory", + initialPath: "memory-bank/dna/topic", + initialData: "topic index\n", + updatedPath: "memory-bank/dna/topic/page.md", + updatedData: "topic page\n", + }, + { + name: "directory to file", + initialPath: "memory-bank/dna/topic/page.md", + initialData: "topic page\n", + updatedPath: "memory-bank/dna/topic", + updatedData: "topic index\n", + }, + } { + t.Run(test.name, func(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + write(t, source, test.initialPath, test.initialData) + initialize(t, repo, source) + if err := os.RemoveAll(filepath.Join(source, "memory-bank", "dna", "topic")); err != nil { + t.Fatal(err) + } + write(t, source, test.updatedPath, test.updatedData) + + report, err := Update(opts(repo, source, "b")) + if err != nil || !report.Applied || report.ConflictCount != 0 { + t.Fatalf("clean topology transition failed: report=%#v err=%v", report, err) + } + if decisionFor(t, report, test.initialPath).Action != Delete { + t.Fatalf("old topology was not deleted: %#v", report.Decisions) + } + if decisionFor(t, report, test.updatedPath).Action != Create { + t.Fatalf("new topology was not created: %#v", report.Decisions) + } + if got := read(t, repo, test.updatedPath); got != test.updatedData { + t.Fatalf("unexpected transitioned payload: %q", got) + } + if strings.HasPrefix(test.initialPath, test.updatedPath+"/") { + if _, err := os.Lstat(filepath.Join(repo, filepath.FromSlash(test.initialPath))); err == nil { + t.Fatal("old descendant still exists after directory-to-file transition") + } + } + lock, exists, err := ReadLock(repo) + if err != nil || !exists { + t.Fatalf("could not read transitioned lock: exists=%v err=%v", exists, err) + } + if _, exists := lock.Files[test.initialPath]; exists { + t.Fatalf("old topology remains in lock: %#v", lock.Files[test.initialPath]) + } + if got := lock.Files[test.updatedPath]; got.Ownership != Managed || got.PayloadDigest != digest([]byte(test.updatedData)) { + t.Fatalf("new topology missing from lock: %#v", got) + } + }) + } +} + +func TestDirectoryToFileTransitionRejectsUntrackedTopology(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + oldPath := "memory-bank/dna/topic/page.md" + newPath := "memory-bank/dna/topic" + write(t, source, oldPath, "old child\n") + initialize(t, repo, source) + lockBefore := read(t, repo, LockFileName) + if err := os.Mkdir(filepath.Join(repo, "memory-bank", "dna", "topic", "project-only"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(filepath.Join(source, "memory-bank", "dna", "topic")); err != nil { + t.Fatal(err) + } + write(t, source, newPath, "new file\n") + + if _, err := Update(opts(repo, source, "b")); err == nil || !strings.Contains(err.Error(), "untracked directory") { + t.Fatalf("expected untracked topology rejection, got %v", err) + } + if got := read(t, repo, oldPath); got != "old child\n" { + t.Fatalf("rejected transition changed managed payload: %q", got) + } + if got := read(t, repo, LockFileName); got != lockBefore { + t.Fatal("rejected transition changed the lock") + } +} diff --git a/internal/ownership/secure_path_unix.go b/internal/ownership/secure_path_unix.go new file mode 100644 index 0000000..acacebb --- /dev/null +++ b/internal/ownership/secure_path_unix.go @@ -0,0 +1,154 @@ +//go:build darwin || linux + +package ownership + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +// openDestinationParent resolves ancestors relative to directory descriptors. +// In particular, an ancestor replaced after it was checked cannot redirect a +// later operation through a symlink. +func openDestinationParent(repo pinnedRepo, relative string, create bool, created *[]string) (int, string, error) { + return openDestinationParentWithMkdir(repo, relative, create, created, unix.Mkdirat) +} + +func openDestinationParentWithMkdir(repo pinnedRepo, relative string, create bool, created *[]string, mkdirat func(int, string, uint32) error) (int, string, error) { + _, osRelative, err := destinationPathLexicalPinned(repo, relative) + if err != nil { + return -1, "", err + } + parts := strings.Split(osRelative, string(filepath.Separator)) + fd, err := unix.Open(repo.root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return -1, "", fmt.Errorf("open repository root: %w", err) + } + closeFD := func() { _ = unix.Close(fd) } + for index, part := range parts[:len(parts)-1] { + next, openErr := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if errors.Is(openErr, unix.ENOENT) && create { + mkdirErr := mkdirat(fd, part, 0o755) + if mkdirErr != nil && !errors.Is(mkdirErr, unix.EEXIST) { + closeFD() + return -1, "", mkdirErr + } + if mkdirErr == nil && created != nil { + createdPath := filepath.Join(repo.root, filepath.Join(parts[:index+1]...)) + if rel, relErr := filepath.Rel(repo.root, createdPath); relErr == nil { + *created = append(*created, filepath.ToSlash(rel)) + } + } + next, openErr = unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + } + if openErr != nil { + closeFD() + return -1, "", fmt.Errorf("open destination parent %q: %w", relative, openErr) + } + _ = unix.Close(fd) + fd = next + } + return fd, parts[len(parts)-1], nil +} + +func secureEnsureDestinationParents(repo pinnedRepo, relative string, created *[]string) error { + fd, _, err := openDestinationParent(repo, relative, true, created) + if err != nil { + return err + } + return unix.Close(fd) +} + +func secureMkdirDestination(repo pinnedRepo, relative string, mode os.FileMode) error { + fd, leaf, err := openDestinationParent(repo, relative, false, nil) + if err != nil { + return err + } + defer unix.Close(fd) + return unix.Mkdirat(fd, leaf, uint32(mode.Perm())) +} + +func secureRenameToDestination(repo pinnedRepo, relative, source string) error { + fd, leaf, err := openDestinationParent(repo, relative, false, nil) + if err != nil { + return err + } + defer unix.Close(fd) + return unix.Renameat(unix.AT_FDCWD, source, fd, leaf) +} + +func secureRenameFromDestination(repo pinnedRepo, relative, destination string) error { + fd, leaf, err := openDestinationParent(repo, relative, false, nil) + if err != nil { + return err + } + defer unix.Close(fd) + return unix.Renameat(fd, leaf, unix.AT_FDCWD, destination) +} + +func secureLinkToDestination(repo pinnedRepo, relative, source string) error { + fd, leaf, err := openDestinationParent(repo, relative, false, nil) + if err != nil { + return err + } + defer unix.Close(fd) + return unix.Linkat(unix.AT_FDCWD, source, fd, leaf, 0) +} + +func secureRemoveDestination(repo pinnedRepo, relative string, directory bool) error { + fd, leaf, err := openDestinationParent(repo, relative, false, nil) + if err != nil { + return err + } + defer unix.Close(fd) + flags := 0 + if directory { + flags = unix.AT_REMOVEDIR + } + return unix.Unlinkat(fd, leaf, flags) +} + +func secureReadDestination(repo pinnedRepo, relative string) (os.FileInfo, []byte, error) { + return secureReadDestinationWithParentOpened(repo, relative, nil) +} + +func secureReadDestinationWithParentOpened(repo pinnedRepo, relative string, parentOpened func()) (os.FileInfo, []byte, error) { + parent, leaf, err := openDestinationParent(repo, relative, false, nil) + if err != nil { + return nil, nil, err + } + defer unix.Close(parent) + if parentOpened != nil { + parentOpened() + } + + fd, err := unix.Openat(parent, leaf, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + return nil, nil, fmt.Errorf("open destination file %q: %w", relative, err) + } + file := os.NewFile(uintptr(fd), relative) + if file == nil { + _ = unix.Close(fd) + return nil, nil, fmt.Errorf("open destination file %q", relative) + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return nil, nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, nil, fmt.Errorf("unsupported destination file %q", relative) + } + data, err := io.ReadAll(file) + if err != nil { + return nil, nil, err + } + return info, data, nil +} diff --git a/internal/ownership/secure_path_unix_test.go b/internal/ownership/secure_path_unix_test.go new file mode 100644 index 0000000..ea3c7e0 --- /dev/null +++ b/internal/ownership/secure_path_unix_test.go @@ -0,0 +1,78 @@ +//go:build darwin || linux + +package ownership + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/unix" +) + +func TestConcurrentParentCreationIsNotRecordedAsTransactionOwned(t *testing.T) { + repo, err := pinRepoRoot(t.TempDir()) + if err != nil { + t.Fatal(err) + } + created := []string{} + mkdirat := func(fd int, path string, mode uint32) error { + if err := unix.Mkdirat(fd, path, mode); err != nil { + return err + } + // Model another process winning the race after Openat reported ENOENT. + return unix.EEXIST + } + + fd, _, err := openDestinationParentWithMkdir(repo, "memory-bank/dna/rule.md", true, &created, mkdirat) + if err != nil { + t.Fatal(err) + } + if closeErr := unix.Close(fd); closeErr != nil && !errors.Is(closeErr, unix.EBADF) { + t.Fatal(closeErr) + } + if len(created) != 0 { + t.Fatalf("concurrently created parent was recorded for rollback: %v", created) + } +} + +func TestSecureReadDestinationKeepsPinnedParentAfterAncestorReplacement(t *testing.T) { + root := t.TempDir() + insideParent := filepath.Join(root, "memory-bank", "dna") + if err := os.MkdirAll(insideParent, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(insideParent, "rule.md"), []byte("inside"), 0o644); err != nil { + t.Fatal(err) + } + outside := t.TempDir() + if err := os.MkdirAll(filepath.Join(outside, "dna"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outside, "dna", "rule.md"), []byte("outside"), 0o644); err != nil { + t.Fatal(err) + } + repo, err := pinRepoRoot(root) + if err != nil { + t.Fatal(err) + } + + var replaceErr error + _, data, err := secureReadDestinationWithParentOpened(repo, "memory-bank/dna/rule.md", func() { + memoryBank := filepath.Join(root, "memory-bank") + replaceErr = os.Rename(memoryBank, filepath.Join(root, "original-memory-bank")) + if replaceErr == nil { + replaceErr = os.Symlink(outside, memoryBank) + } + }) + if replaceErr != nil { + t.Fatal(replaceErr) + } + if err != nil { + t.Fatal(err) + } + if string(data) != "inside" { + t.Fatalf("read was redirected after ancestor replacement: %q", data) + } +} diff --git a/internal/ownership/secure_path_windows.go b/internal/ownership/secure_path_windows.go new file mode 100644 index 0000000..37f0238 --- /dev/null +++ b/internal/ownership/secure_path_windows.go @@ -0,0 +1,262 @@ +//go:build windows + +package ownership + +import ( + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +const fileCreated = 2 + +type fileNameInformation struct { + ReplaceIfExists uint32 + RootDirectory windows.Handle + FileNameLength uint32 + FileName [1]uint16 +} + +func openRepoHandle(repo pinnedRepo) (windows.Handle, error) { + name, err := windows.UTF16PtrFromString(repo.root) + if err != nil { + return 0, err + } + handle, err := windows.CreateFile(name, windows.FILE_GENERIC_READ, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0) + if err != nil { + return 0, err + } + file := os.NewFile(uintptr(handle), repo.root) + info, statErr := file.Stat() + if statErr != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || !os.SameFile(repo.info, info) { + _ = file.Close() + if statErr != nil { + return 0, statErr + } + return 0, fmt.Errorf("unsafe repo root: %s changed during update", repo.root) + } + // The caller owns the handle; do not close the temporary os.File wrapper. + runtime.SetFinalizer(file, nil) + return handle, nil +} + +func ntOpenRelative(parent windows.Handle, name string, access, disposition, options uint32) (windows.Handle, uintptr, error) { + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, 0, err + } + attributes := windows.OBJECT_ATTRIBUTES{ + Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})), + RootDirectory: parent, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE, + } + var status windows.IO_STATUS_BLOCK + var handle windows.Handle + err = windows.NtCreateFile(&handle, access, &attributes, &status, nil, 0, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + disposition, options|windows.FILE_OPEN_REPARSE_POINT, 0, 0) + return handle, status.Information, err +} + +func openDestinationParent(repo pinnedRepo, relative string, create bool, created *[]string) (windows.Handle, string, error) { + _, osRelative, err := destinationPathLexicalPinned(repo, relative) + if err != nil { + return 0, "", err + } + parts := strings.Split(osRelative, string(filepath.Separator)) + handle, err := openRepoHandle(repo) + if err != nil { + return 0, "", fmt.Errorf("open repository root: %w", err) + } + for index, part := range parts[:len(parts)-1] { + disposition := uint32(windows.FILE_OPEN) + if create { + disposition = windows.FILE_OPEN_IF + } + next, information, openErr := ntOpenRelative(handle, part, windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE, disposition, windows.FILE_DIRECTORY_FILE) + windows.CloseHandle(handle) + if openErr != nil { + return 0, "", fmt.Errorf("open destination parent %q: %w", relative, openErr) + } + handle = next + if information == fileCreated && created != nil { + *created = append(*created, filepath.ToSlash(filepath.Join(parts[:index+1]...))) + } + } + return handle, parts[len(parts)-1], nil +} + +func secureEnsureDestinationParents(repo pinnedRepo, relative string, created *[]string) error { + handle, _, err := openDestinationParent(repo, relative, true, created) + if err == nil { + err = windows.CloseHandle(handle) + } + return err +} + +func secureMkdirDestination(repo pinnedRepo, relative string, mode os.FileMode) error { + parent, leaf, err := openDestinationParent(repo, relative, false, nil) + if err != nil { + return err + } + defer windows.CloseHandle(parent) + handle, _, err := ntOpenRelative(parent, leaf, windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE, windows.FILE_CREATE, windows.FILE_DIRECTORY_FILE) + if err == nil { + err = windows.CloseHandle(handle) + } + _ = mode // Windows has no Unix directory permission mode. + return err +} + +func openAbsoluteForMutation(path string, directory bool) (windows.Handle, error) { + name, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + flags := uint32(windows.FILE_FLAG_OPEN_REPARSE_POINT) + if directory { + flags |= windows.FILE_FLAG_BACKUP_SEMANTICS + } + return windows.CreateFile(name, windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE|windows.DELETE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, flags, 0) +} + +func nameInformation(root windows.Handle, leaf string, replace bool) ([]byte, error) { + name, err := windows.UTF16FromString(leaf) + if err != nil { + return nil, err + } + name = name[:len(name)-1] + var header fileNameInformation + buffer := make([]byte, int(unsafe.Offsetof(header.FileName))+len(name)*2) + info := (*fileNameInformation)(unsafe.Pointer(&buffer[0])) + if replace { + info.ReplaceIfExists = windows.FILE_RENAME_REPLACE_IF_EXISTS | windows.FILE_RENAME_POSIX_SEMANTICS + } + info.RootDirectory = root + info.FileNameLength = uint32(len(name) * 2) + copy(unsafe.Slice(&info.FileName[0], len(name)), name) + return buffer, nil +} + +func setName(handle, root windows.Handle, leaf string, class uint32, replace bool) error { + buffer, err := nameInformation(root, leaf, replace) + if err != nil { + return err + } + var status windows.IO_STATUS_BLOCK + return windows.NtSetInformationFile(handle, &status, &buffer[0], uint32(len(buffer)), class) +} + +func secureRenameToDestination(repo pinnedRepo, relative, source string) error { + parent, leaf, err := openDestinationParent(repo, relative, false, nil) + if err != nil { + return err + } + defer windows.CloseHandle(parent) + sourceHandle, err := openAbsoluteForMutation(source, false) + if err != nil { + return err + } + defer windows.CloseHandle(sourceHandle) + return setName(sourceHandle, parent, leaf, windows.FileRenameInformation, false) +} + +func secureRenameFromDestination(repo pinnedRepo, relative, destination string) error { + parent, leaf, err := openDestinationParent(repo, relative, false, nil) + if err != nil { + return err + } + defer windows.CloseHandle(parent) + source, _, err := ntOpenRelative(parent, leaf, windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE|windows.DELETE, windows.FILE_OPEN, 0) + if err != nil { + return err + } + defer windows.CloseHandle(source) + destinationParent, err := openAbsoluteForMutation(filepath.Dir(destination), true) + if err != nil { + return err + } + defer windows.CloseHandle(destinationParent) + return setName(source, destinationParent, filepath.Base(destination), windows.FileRenameInformation, false) +} + +func secureLinkToDestination(repo pinnedRepo, relative, source string) error { + parent, leaf, err := openDestinationParent(repo, relative, false, nil) + if err != nil { + return err + } + defer windows.CloseHandle(parent) + sourceHandle, err := openAbsoluteForMutation(source, false) + if err != nil { + return err + } + defer windows.CloseHandle(sourceHandle) + return setName(sourceHandle, parent, leaf, windows.FileLinkInformation, false) +} + +func secureRemoveDestination(repo pinnedRepo, relative string, directory bool) error { + parent, leaf, err := openDestinationParent(repo, relative, false, nil) + if err != nil { + return err + } + defer windows.CloseHandle(parent) + options := uint32(0) + if directory { + options = windows.FILE_DIRECTORY_FILE + } + handle, _, err := ntOpenRelative(parent, leaf, windows.DELETE, windows.FILE_OPEN, options) + if err != nil { + return err + } + defer windows.CloseHandle(handle) + flags := uint32(windows.FILE_DISPOSITION_DELETE | windows.FILE_DISPOSITION_POSIX_SEMANTICS | windows.FILE_DISPOSITION_IGNORE_READONLY_ATTRIBUTE) + var status windows.IO_STATUS_BLOCK + return windows.NtSetInformationFile(handle, &status, (*byte)(unsafe.Pointer(&flags)), uint32(unsafe.Sizeof(flags)), windows.FileDispositionInformationEx) +} + +func secureReadDestination(repo pinnedRepo, relative string) (os.FileInfo, []byte, error) { + return secureReadDestinationWithParentOpened(repo, relative, nil) +} + +func secureReadDestinationWithParentOpened(repo pinnedRepo, relative string, parentOpened func()) (os.FileInfo, []byte, error) { + parent, leaf, err := openDestinationParent(repo, relative, false, nil) + if err != nil { + return nil, nil, err + } + defer windows.CloseHandle(parent) + if parentOpened != nil { + parentOpened() + } + + handle, _, err := ntOpenRelative(parent, leaf, windows.FILE_GENERIC_READ, windows.FILE_OPEN, windows.FILE_NON_DIRECTORY_FILE) + if err != nil { + return nil, nil, fmt.Errorf("open destination file %q: %w", relative, err) + } + file := os.NewFile(uintptr(handle), relative) + if file == nil { + _ = windows.CloseHandle(handle) + return nil, nil, fmt.Errorf("open destination file %q", relative) + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return nil, nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, nil, fmt.Errorf("unsupported destination file %q", relative) + } + data, err := io.ReadAll(file) + if err != nil { + return nil, nil, err + } + return info, data, nil +} diff --git a/internal/ownership/source.go b/internal/ownership/source.go new file mode 100644 index 0000000..b3815f0 --- /dev/null +++ b/internal/ownership/source.go @@ -0,0 +1,155 @@ +package ownership + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type pinnedSource struct { + root string + info fs.FileInfo +} + +func pinSourceRoot(root string) (pinnedSource, error) { + if root == "" { + return pinnedSource{}, errors.New("source root is required") + } + absRoot, err := filepath.Abs(root) + if err != nil { + return pinnedSource{}, fmt.Errorf("resolve source root: %w", err) + } + resolvedRoot, err := filepath.EvalSymlinks(absRoot) + if err != nil { + return pinnedSource{}, fmt.Errorf("resolve source root: %w", err) + } + info, err := os.Lstat(resolvedRoot) + if err != nil { + return pinnedSource{}, fmt.Errorf("inspect source root: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return pinnedSource{}, fmt.Errorf("source root is not a directory: %s", resolvedRoot) + } + return pinnedSource{root: resolvedRoot, info: info}, nil +} + +func inspectSourceRoot(source pinnedSource) error { + info, err := os.Lstat(source.root) + if err != nil { + return fmt.Errorf("inspect source root: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() || !os.SameFile(source.info, info) { + return fmt.Errorf("unsafe source root: %s changed during update", source.root) + } + return nil +} + +func rejectOverlappingRoots(repo pinnedRepo, source pinnedSource) error { + if os.SameFile(repo.info, source.info) || pathContains(repo.root, source.root) || pathContains(source.root, repo.root) { + return fmt.Errorf("source root and repo root overlap: source=%s repo=%s", source.root, repo.root) + } + return nil +} + +func pathContains(parent, child string) bool { + relative, err := filepath.Rel(parent, child) + if err != nil { + return false + } + return relative == "." || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} + +func verifySourceCheckout(root, expectedRef string) error { + topLevel, err := gitOutput(root, "rev-parse", "--show-toplevel") + if err != nil { + return fmt.Errorf("verify source checkout: %w", err) + } + topLevelInfo, err := os.Stat(topLevel) + if err != nil { + return fmt.Errorf("inspect Git checkout root %q: %w", topLevel, err) + } + rootInfo, err := os.Stat(root) + if err != nil { + return fmt.Errorf("inspect source root %q: %w", root, err) + } + if !os.SameFile(rootInfo, topLevelInfo) { + return fmt.Errorf("source root must be the Git checkout root: source=%s checkout=%s", root, topLevel) + } + head, err := gitOutput(root, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return fmt.Errorf("resolve source checkout HEAD: %w", err) + } + if !strings.EqualFold(head, expectedRef) { + return fmt.Errorf("source ref does not match checkout HEAD: got %s, want %s", expectedRef, head) + } + status, err := gitOutput(root, "status", "--porcelain=v1", "--untracked-files=all") + if err != nil { + return fmt.Errorf("inspect source checkout status: %w", err) + } + if status != "" { + return errors.New("source checkout is dirty; commit or discard changes before init/update") + } + payloadStatus, err := gitOutput(root, "status", "--porcelain=v1", "--untracked-files=all", "--ignored=matching", "--", "memory-bank") + if err != nil { + return fmt.Errorf("inspect source template status: %w", err) + } + if payloadStatus != "" { + return errors.New("source memory-bank tree contains uncommitted or ignored payloads") + } + if err := verifySourcePayload(root, expectedRef); err != nil { + return err + } + return nil +} + +func verifySourcePayload(root, expectedRef string) error { + tree, err := gitOutput(root, "ls-tree", "-rz", "--full-tree", expectedRef, "--", "memory-bank") + if err != nil { + return fmt.Errorf("inspect pinned source payload: %w", err) + } + expected := make(map[string]string) + for _, record := range strings.Split(tree, "\x00") { + if record == "" { + continue + } + header, path, found := strings.Cut(record, "\t") + fields := strings.Fields(header) + if !found || len(fields) != 3 { + return errors.New("inspect pinned source payload: malformed Git tree entry") + } + mode, objectType, objectID := fields[0], fields[1], fields[2] + if objectType != "blob" || mode != "100644" && mode != "100755" { + return fmt.Errorf("pinned source payload contains unsupported entry: %q", path) + } + expected[path] = objectID + } + if len(expected) == 0 { + return errors.New("pinned source commit has no memory-bank payload") + } + + return nil +} + +func gitOutput(root string, arguments ...string) (string, error) { + output, err := gitBytes(root, arguments...) + return strings.TrimSpace(string(output)), err +} + +func gitBytes(root string, arguments ...string) ([]byte, error) { + commandArguments := append([]string{"-C", root}, arguments...) + command := exec.Command("git", commandArguments...) + command.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0") + output, err := command.CombinedOutput() + if err != nil { + result := strings.TrimSpace(string(output)) + if result == "" { + return nil, err + } + return nil, fmt.Errorf("%s: %w", result, err) + } + return output, nil +} diff --git a/internal/ownership/source_test.go b/internal/ownership/source_test.go new file mode 100644 index 0000000..6c2e759 --- /dev/null +++ b/internal/ownership/source_test.go @@ -0,0 +1,213 @@ +package ownership + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestInitRejectsOverlappingSourceAndRepoRoots(t *testing.T) { + for _, test := range []struct { + name string + source func(*testing.T, string) string + }{ + {name: "same root", source: func(_ *testing.T, repo string) string { return repo }}, + {name: "symlink alias", source: func(t *testing.T, repo string) string { + alias := filepath.Join(t.TempDir(), "source-alias") + symlinkForTest(t, repo, alias) + return alias + }}, + {name: "nested source", source: func(t *testing.T, repo string) string { + nested := filepath.Join(repo, "template") + if err := os.Mkdir(nested, 0o755); err != nil { + t.Fatal(err) + } + return nested + }}, + } { + t.Run(test.name, func(t *testing.T) { + repo := t.TempDir() + source := test.source(t, repo) + write(t, source, "memory-bank/dna/rule.md", "downstream\n") + report, err := Init(opts(repo, source, "a")) + if err == nil || !strings.Contains(err.Error(), "overlap") { + t.Fatalf("expected source/repo overlap error, got report=%#v err=%v", report, err) + } + if _, err := os.Lstat(filepath.Join(repo, LockFileName)); !os.IsNotExist(err) { + t.Fatalf("overlapping source created a lock: %v", err) + } + }) + } +} + +func TestUpdateCannotUseDownstreamRepoAsTemplateSource(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/dna/rule.md" + write(t, source, path, "template\n") + initialize(t, repo, source) + write(t, repo, path, "downstream drift\n") + lockBefore := read(t, repo, LockFileName) + + report, err := Update(opts(repo, repo, "b")) + if err == nil || !strings.Contains(err.Error(), "overlap") { + t.Fatalf("expected self-source rejection, got report=%#v err=%v", report, err) + } + if got := read(t, repo, path); got != "downstream drift\n" { + t.Fatalf("self-source update changed downstream payload: %q", got) + } + if got := read(t, repo, LockFileName); got != lockBefore { + t.Fatal("self-source update changed the lock") + } +} + +func TestSourceRefMustMatchCleanGitCheckout(t *testing.T) { + source := t.TempDir() + write(t, source, "memory-bank/dna/rule.md", "committed\n") + commit := commitTestSource(t, source) + + options := Options{ + RepoRoot: t.TempDir(), + SourceRoot: source, + TemplateVersion: "v1", + SourceRef: strings.Repeat("f", 40), + DryRun: true, + } + if _, err := Init(options); err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("expected mismatched source ref error, got %v", err) + } + + options.RepoRoot = t.TempDir() + options.SourceRef = commit + write(t, source, "memory-bank/dna/rule.md", "dirty\n") + if _, err := Init(options); err == nil || !strings.Contains(err.Error(), "dirty") { + t.Fatalf("expected dirty source checkout error, got %v", err) + } + + write(t, source, "memory-bank/dna/rule.md", "committed\n") + options.RepoRoot = t.TempDir() + report, err := Init(options) + if err != nil || !report.DryRun || report.Applied { + t.Fatalf("clean matching checkout was rejected: report=%#v err=%v", report, err) + } +} + +func TestPinnedSourceObjectsIgnoreHiddenWorktreeChanges(t *testing.T) { + for _, test := range []struct { + name string + flag string + mutate func(*testing.T, string) + }{ + { + name: "assume unchanged modified file", + flag: "--assume-unchanged", + mutate: func(t *testing.T, source string) { + write(t, source, "memory-bank/dna/rule.md", "modified but hidden\n") + }, + }, + { + name: "skip worktree missing file", + flag: "--skip-worktree", + mutate: func(t *testing.T, source string) { + if err := os.Remove(filepath.Join(source, "memory-bank/dna/rule.md")); err != nil { + t.Fatal(err) + } + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + source := t.TempDir() + write(t, source, "memory-bank/dna/rule.md", "committed\n") + commit := commitTestSource(t, source) + runGitTest(t, source, "update-index", test.flag, "memory-bank/dna/rule.md") + test.mutate(t, source) + if status := runGitTest(t, source, "status", "--porcelain=v1", "--untracked-files=all"); status != "" { + t.Fatalf("test mutation was not hidden from porcelain status: %q", status) + } + + options := Options{ + RepoRoot: t.TempDir(), + SourceRoot: source, + TemplateVersion: "v1", + SourceRef: commit, + } + report, err := Init(options) + if err != nil || !report.Applied { + t.Fatalf("pinned object install failed: report=%#v err=%v", report, err) + } + if got := read(t, options.RepoRoot, "memory-bank/dna/rule.md"); got != "committed\n" { + t.Fatalf("installed worktree mutation instead of pinned blob: %q", got) + } + }) + } +} + +func TestCleanCheckoutWithTextConversionUsesPinnedBlob(t *testing.T) { + source := t.TempDir() + write(t, source, "memory-bank/dna/rule.md", "canonical\n") + write(t, source, ".gitattributes", "*.md text eol=crlf\n") + commit := commitTestSource(t, source) + if err := os.Remove(filepath.Join(source, "memory-bank/dna/rule.md")); err != nil { + t.Fatal(err) + } + runGitTest(t, source, "checkout", "--", "memory-bank/dna/rule.md") + if got := read(t, source, "memory-bank/dna/rule.md"); got != "canonical\r\n" { + t.Fatalf("fixture did not apply CRLF checkout conversion: %q", got) + } + if status := runGitTest(t, source, "status", "--porcelain=v1"); status != "" { + t.Fatalf("text-converted checkout is not clean: %q", status) + } + repo := t.TempDir() + report, err := Init(Options{RepoRoot: repo, SourceRoot: source, TemplateVersion: "v1", SourceRef: commit}) + if err != nil || !report.Applied { + t.Fatalf("clean text-converted checkout was rejected: report=%#v err=%v", report, err) + } + if got := read(t, repo, "memory-bank/dna/rule.md"); got != "canonical\n" { + t.Fatalf("installed non-canonical worktree bytes: %q", got) + } +} + +func TestPinnedSourceExecutableModeIsInstalled(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not expose Unix executable permission bits") + } + source := t.TempDir() + path := "memory-bank/flows/tool.md" + write(t, source, path, "tool\n") + if err := os.Chmod(filepath.Join(source, filepath.FromSlash(path)), 0o755); err != nil { + t.Fatal(err) + } + commit := commitTestSource(t, source) + repo := t.TempDir() + report, err := Init(Options{RepoRoot: repo, SourceRoot: source, TemplateVersion: "v1", SourceRef: commit}) + if err != nil || !report.Applied { + t.Fatalf("executable source install failed: report=%#v err=%v", report, err) + } + info, err := os.Stat(filepath.Join(repo, filepath.FromSlash(path))) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o755 { + t.Fatalf("executable source mode was lost: got %04o", got) + } +} + +func commitTestSource(t *testing.T, root string) string { + t.Helper() + runGitTest(t, root, "init", "--quiet") + runGitTest(t, root, "add", "--all") + runGitTest(t, root, "-c", "user.name=Memory Bank Tests", "-c", "user.email=tests@example.invalid", "commit", "--quiet", "-m", "source") + return runGitTest(t, root, "rev-parse", "HEAD") +} + +func runGitTest(t *testing.T, root string, arguments ...string) string { + t.Helper() + command := exec.Command("git", append([]string{"-C", root}, arguments...)...) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", arguments, err, output) + } + return strings.TrimSpace(string(output)) +} diff --git a/internal/ownership/symlink_test.go b/internal/ownership/symlink_test.go new file mode 100644 index 0000000..bb765bb --- /dev/null +++ b/internal/ownership/symlink_test.go @@ -0,0 +1,175 @@ +package ownership + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func symlinkForTest(t *testing.T, target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks are unavailable: %v", err) + } +} + +func TestInitRejectsDestinationSymlinkAncestor(t *testing.T) { + repo, source, outside := t.TempDir(), t.TempDir(), t.TempDir() + path := "memory-bank/dna/rule.md" + write(t, source, path, "template\n") + if err := os.MkdirAll(filepath.Join(repo, "memory-bank"), 0o755); err != nil { + t.Fatal(err) + } + symlinkForTest(t, outside, filepath.Join(repo, "memory-bank", "dna")) + + report, err := Init(opts(repo, source, "a")) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected destination symlink error, got report=%#v err=%v", report, err) + } + if _, err := os.Lstat(filepath.Join(outside, "rule.md")); !os.IsNotExist(err) { + t.Fatalf("init wrote through destination symlink: %v", err) + } + if _, err := os.Lstat(filepath.Join(repo, LockFileName)); !os.IsNotExist(err) { + t.Fatalf("failed init created a lock: %v", err) + } +} + +func TestReadLockRejectsSymlinkLeaf(t *testing.T) { + source, lockOwner, repo := t.TempDir(), t.TempDir(), t.TempDir() + write(t, source, "memory-bank/dna/rule.md", "template\n") + initialize(t, lockOwner, source) + outsideLock := filepath.Join(lockOwner, LockFileName) + symlinkForTest(t, outsideLock, filepath.Join(repo, LockFileName)) + + _, exists, err := ReadLock(repo) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected lock symlink error, got exists=%v err=%v", exists, err) + } + if exists { + t.Fatal("symlinked lock was reported as an owned repository lock") + } +} + +func TestUpdateRejectsSymlinkAncestorInjectedBeforeMutation(t *testing.T) { + repo, source, outside := t.TempDir(), t.TempDir(), t.TempDir() + path := "memory-bank/dna/rule.md" + write(t, source, path, "one\n") + initialize(t, repo, source) + write(t, source, path, "two\n") + write(t, outside, "rule.md", "outside sentinel\n") + lockBefore := read(t, repo, LockFileName) + + options := opts(repo, source, "b") + injected := false + options.BeforeMutation = func(decision Decision) error { + if decision.Path != path { + return nil + } + parent := filepath.Join(repo, "memory-bank", "dna") + if err := os.Rename(parent, parent+".original"); err != nil { + return err + } + if err := os.Symlink(outside, parent); err != nil { + if restoreErr := os.Rename(parent+".original", parent); restoreErr != nil { + t.Fatalf("symlink unavailable (%v) and parent restore failed: %v", err, restoreErr) + } + t.Skipf("symlinks are unavailable: %v", err) + } + injected = true + return nil + } + + report, err := Update(options) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected apply-time symlink error, got report=%#v err=%v", report, err) + } + if !injected { + t.Fatal("test did not inject the destination symlink") + } + if got := read(t, outside, "rule.md"); got != "outside sentinel\n" { + t.Fatalf("update wrote outside the repository: %q", got) + } + if got := read(t, repo, LockFileName); got != lockBefore { + t.Fatal("failed update changed the ownership lock") + } +} + +func TestInitRejectsRepoRootReboundAfterPlanning(t *testing.T) { + repo, source, outside := t.TempDir(), t.TempDir(), t.TempDir() + write(t, source, "memory-bank/dna/rule.md", "template\n") + movedRepo := repo + ".original" + rebound := false + defer func() { + if !rebound { + return + } + _ = os.Remove(repo) + _ = os.Rename(movedRepo, repo) + }() + + options := opts(repo, source, "a") + options.Now = func() time.Time { + if err := os.Rename(repo, movedRepo); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, repo); err != nil { + if restoreErr := os.Rename(movedRepo, repo); restoreErr != nil { + t.Fatalf("symlink unavailable (%v) and repo restore failed: %v", err, restoreErr) + } + t.Skipf("symlinks are unavailable: %v", err) + } + rebound = true + return fixedTime + } + report, err := Init(options) + if err == nil || !strings.Contains(err.Error(), "repo root") { + t.Fatalf("expected rebound repo-root error, got report=%#v err=%v", report, err) + } + if _, err := os.Lstat(filepath.Join(outside, "memory-bank")); !os.IsNotExist(err) { + t.Fatalf("init wrote through rebound repo root: %v", err) + } + if _, err := os.Lstat(filepath.Join(outside, LockFileName)); !os.IsNotExist(err) { + t.Fatalf("init wrote lock through rebound repo root: %v", err) + } +} + +func TestInitRejectsRepoRootReplacedByAnotherDirectory(t *testing.T) { + repo, source, replacement := t.TempDir(), t.TempDir(), t.TempDir() + write(t, source, "memory-bank/dna/rule.md", "template\n") + movedRepo := repo + ".original" + rebound := false + defer func() { + if !rebound { + return + } + _ = os.Rename(repo, replacement) + _ = os.Rename(movedRepo, repo) + }() + + options := opts(repo, source, "a") + options.Now = func() time.Time { + if err := os.Rename(repo, movedRepo); err != nil { + t.Fatal(err) + } + if err := os.Rename(replacement, repo); err != nil { + if restoreErr := os.Rename(movedRepo, repo); restoreErr != nil { + t.Fatalf("repo rebind failed (%v) and repo restore failed: %v", err, restoreErr) + } + t.Fatal(err) + } + rebound = true + return fixedTime + } + report, err := Init(options) + if err == nil || !strings.Contains(err.Error(), "changed during update") { + t.Fatalf("expected pinned-root identity error, got report=%#v err=%v", report, err) + } + if _, err := os.Lstat(filepath.Join(repo, "memory-bank")); !os.IsNotExist(err) { + t.Fatalf("init wrote into replacement repo root: %v", err) + } + if _, err := os.Lstat(filepath.Join(repo, LockFileName)); !os.IsNotExist(err) { + t.Fatalf("init wrote lock into replacement repo root: %v", err) + } +} diff --git a/internal/ownership/topology.go b/internal/ownership/topology.go new file mode 100644 index 0000000..47fdef4 --- /dev/null +++ b/internal/ownership/topology.go @@ -0,0 +1,245 @@ +package ownership + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +type topologySnapshot struct { + files []destinationPrecondition + directories []string +} + +type removedDirectory struct { + path string + mode fs.FileMode +} + +func inspectDestinationForPlan(repo pinnedRepo, relative string, cleanRemovals map[string]string) (string, bool, *topologySnapshot, error) { + target, osRelative, err := destinationPathLexicalPinned(repo, relative) + if err != nil { + return "", false, nil, err + } + current := repo.root + components := splitPath(osRelative) + for index, component := range components { + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + return "", false, nil, nil + } + if err != nil { + return "", false, nil, fmt.Errorf("inspect destination path %q: %w", relative, err) + } + componentPath, err := filepath.Rel(repo.root, current) + if err != nil { + return "", false, nil, err + } + componentPath = filepath.ToSlash(componentPath) + if info.Mode()&os.ModeSymlink != 0 { + return "", false, nil, fmt.Errorf("unsafe destination path %q: component %q is a symlink", relative, componentPath) + } + if index < len(components)-1 { + if info.IsDir() { + continue + } + if !info.Mode().IsRegular() || cleanRemovals[componentPath] == "" { + return "", false, nil, fmt.Errorf("destination topology for %q is blocked by %q", relative, componentPath) + } + digest, err := digestRegularDestination(repo, componentPath, info) + if err != nil { + return "", false, nil, err + } + if digest != cleanRemovals[componentPath] { + return "", false, nil, fmt.Errorf("destination topology changed at %q", componentPath) + } + return "", false, &topologySnapshot{files: []destinationPrecondition{{path: componentPath, digest: digest}}}, nil + } + if info.Mode().IsRegular() { + digest, err := digestRegularDestination(repo, relative, info) + return digest, true, nil, err + } + if !info.IsDir() { + return "", false, nil, fmt.Errorf("unsupported destination file %q", relative) + } + snapshot, err := snapshotDirectory(repo, target) + if err != nil { + return "", false, nil, err + } + if len(snapshot.files) == 0 { + return "", false, nil, fmt.Errorf("destination topology for %q is an untracked directory", relative) + } + for _, file := range snapshot.files { + if cleanRemovals[file.path] != file.digest { + return "", false, nil, fmt.Errorf("destination topology for %q contains non-removable file %q", relative, file.path) + } + } + impliedDirectories := map[string]bool{relative: true} + for _, file := range snapshot.files { + for directory := filepath.ToSlash(filepath.Dir(filepath.FromSlash(file.path))); pathContainsRelative(relative, directory); directory = filepath.ToSlash(filepath.Dir(filepath.FromSlash(directory))) { + impliedDirectories[directory] = true + if directory == relative { + break + } + } + } + for _, directory := range snapshot.directories { + if !impliedDirectories[directory] { + return "", false, nil, fmt.Errorf("destination topology for %q contains untracked directory %q", relative, directory) + } + } + return "", false, snapshot, nil + } + return "", false, nil, nil +} + +func pathContainsRelative(parent, child string) bool { + return child == parent || strings.HasPrefix(child, parent+"/") +} + +func splitPath(relative string) []string { + return strings.Split(relative, string(filepath.Separator)) +} + +func snapshotDirectory(repo pinnedRepo, root string) (*topologySnapshot, error) { + result := &topologySnapshot{} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + info, err := entry.Info() + if err != nil { + return err + } + relative, err := filepath.Rel(repo.root, path) + if err != nil { + return err + } + relative = filepath.ToSlash(relative) + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("unsafe destination path %q: component is a symlink", relative) + } + if info.IsDir() { + result.directories = append(result.directories, relative) + return nil + } + if !info.Mode().IsRegular() { + return fmt.Errorf("unsupported destination file %q", relative) + } + payloadDigest, err := digestRegularDestination(repo, relative, info) + if err != nil { + return err + } + result.files = append(result.files, destinationPrecondition{path: relative, digest: payloadDigest}) + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(result.files, func(i, j int) bool { return result.files[i].path < result.files[j].path }) + sort.Strings(result.directories) + return result, nil +} + +func digestRegularDestination(repo pinnedRepo, relative string, expected fs.FileInfo) (string, error) { + info, data, err := secureReadDestination(repo, relative) + if err != nil { + return "", err + } + if !os.SameFile(expected, info) { + return "", fmt.Errorf("destination file changed while reading: %s", relative) + } + return digest(data), nil +} + +func verifyTopologySnapshot(repo pinnedRepo, relative string, expected *topologySnapshot) error { + cleanRemovals := make(map[string]string, len(expected.files)) + for _, file := range expected.files { + cleanRemovals[file.path] = file.digest + } + _, exists, current, err := inspectDestinationForPlan(repo, relative, cleanRemovals) + if err != nil { + return err + } + if exists || current == nil || !sameTopology(current, expected) { + return errors.New("destination topology changed while update was being planned") + } + return nil +} + +func sameTopology(left, right *topologySnapshot) bool { + if len(left.files) != len(right.files) || len(left.directories) != len(right.directories) { + return false + } + for index := range left.files { + if left.files[index] != right.files[index] { + return false + } + } + for index := range left.directories { + if left.directories[index] != right.directories[index] { + return false + } + } + return true +} + +func prepareTopologyDestination(repo pinnedRepo, relative string, expected *topologySnapshot, removed *[]removedDirectory) error { + target, _, err := destinationPathLexicalPinned(repo, relative) + if err != nil { + return err + } + info, err := os.Lstat(target) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("destination topology did not clear after planned deletions") + } + current, err := snapshotDirectory(repo, target) + if err != nil { + return err + } + if len(current.files) != 0 || !sameStrings(current.directories, expected.directories) { + return errors.New("destination directory topology changed during update") + } + for index := len(current.directories) - 1; index >= 0; index-- { + directoryPath := current.directories[index] + directoryTarget, err := destinationPathPinned(repo, directoryPath) + if err != nil { + return err + } + directoryInfo, err := os.Lstat(directoryTarget) + if err != nil { + return err + } + if !directoryInfo.IsDir() || directoryInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("destination directory changed before removal: %s", directoryPath) + } + if err := secureRemoveDestination(repo, directoryPath, true); err != nil { + return fmt.Errorf("remove replaced directory %s: %w", directoryPath, err) + } + *removed = append(*removed, removedDirectory{path: directoryPath, mode: directoryInfo.Mode().Perm()}) + } + return nil +} + +func sameStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} diff --git a/internal/ownership/transaction_test.go b/internal/ownership/transaction_test.go new file mode 100644 index 0000000..b0bdefa --- /dev/null +++ b/internal/ownership/transaction_test.go @@ -0,0 +1,530 @@ +package ownership + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTransactionStagesAllPayloadsBeforeFirstMutation(t *testing.T) { + repo := t.TempDir() + first := "memory-bank/dna/a.md" + second := "memory-bank/dna/b.md" + write(t, repo, first, "a1\n") + write(t, repo, second, "b1\n") + + stageErr := errors.New("simulated staging failure") + writeCalls := 0 + renameCalls := 0 + mutationCalls := 0 + options := Options{ + RepoRoot: repo, + BeforeMutation: func(Decision) error { + mutationCalls++ + return nil + }, + } + mutations := []mutation{ + { + decision: Decision{Path: first, Action: UpdateFile}, + data: []byte("a2\n"), + expectedExists: true, + }, + { + decision: Decision{Path: second, Action: UpdateFile}, + data: []byte("b2\n"), + expectedExists: true, + }, + } + ops := transactionOps{ + writeFile: func(path string, data []byte, mode os.FileMode) error { + writeCalls++ + if writeCalls == 2 { + return stageErr + } + return os.WriteFile(path, data, mode) + }, + rename: func(oldPath, newPath string) error { + renameCalls++ + return os.Rename(oldPath, newPath) + }, + } + + err := applyAtomicallyWithOps(options, mutations, ops) + if !errors.Is(err, stageErr) { + t.Fatalf("expected staging error, got %v", err) + } + if mutationCalls != 0 || renameCalls != 0 { + t.Fatalf("staging failure reached mutation phase: hooks=%d renames=%d", mutationCalls, renameCalls) + } + if got := read(t, repo, first); got != "a1\n" { + t.Fatalf("first target changed during staging: %q", got) + } + if got := read(t, repo, second); got != "b1\n" { + t.Fatalf("second target changed during staging: %q", got) + } + assertNoTransactionStaging(t, repo) +} + +func TestTransactionSurfacesRollbackFailure(t *testing.T) { + repo := t.TempDir() + first := "memory-bank/dna/a.md" + second := "memory-bank/dna/b.md" + write(t, repo, first, "a1\n") + write(t, repo, second, "b1\n") + + applyErr := errors.New("simulated interruption") + rollbackErr := errors.New("simulated rollback failure") + mutationCalls := 0 + options := Options{ + RepoRoot: repo, + BeforeMutation: func(Decision) error { + mutationCalls++ + if mutationCalls == 2 { + return applyErr + } + return nil + }, + } + mutations := []mutation{ + { + decision: Decision{Path: first, Action: UpdateFile}, + data: []byte("a2\n"), + expectedExists: true, + }, + { + decision: Decision{Path: second, Action: UpdateFile}, + data: []byte("b2\n"), + expectedExists: true, + }, + } + pinnedRoot, err := pinRepoRoot(repo) + if err != nil { + t.Fatal(err) + } + firstTarget, err := destinationPathPinned(pinnedRoot, first) + if err != nil { + t.Fatal(err) + } + ops := transactionOps{ + writeFile: os.WriteFile, + rename: os.Rename, + link: func(oldPath, newPath string) error { + if newPath == firstTarget && filepath.Base(filepath.Dir(oldPath)) == "old" { + return rollbackErr + } + return os.Link(oldPath, newPath) + }, + } + + err = applyAtomicallyWithOps(options, mutations, ops) + if !errors.Is(err, applyErr) { + t.Fatalf("apply error was lost: %v", err) + } + if !errors.Is(err, rollbackErr) { + t.Fatalf("rollback error was lost: %v", err) + } + if !strings.Contains(err.Error(), "rollback incomplete") { + t.Fatalf("rollback failure lacks context: %v", err) + } + if got := read(t, repo, second); got != "b1\n" { + t.Fatalf("unreached target changed: %q", got) + } + staging, err := filepath.Glob(filepath.Join(repo, ".memory-bank-update-*")) + if err != nil { + t.Fatal(err) + } + if len(staging) != 1 { + t.Fatalf("expected retained recovery staging, got %v", staging) + } + if got := read(t, staging[0], "old/000000"); got != "a1\n" { + t.Fatalf("recovery staging lost the original payload: %q", got) + } +} + +func TestLockMutationFailureRestoresAllPayloads(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + first := "memory-bank/dna/a.md" + second := "memory-bank/dna/b.md" + write(t, source, first, "a1\n") + write(t, source, second, "b1\n") + initialize(t, repo, source) + write(t, source, first, "a2\n") + write(t, source, second, "b2\n") + lockBefore := read(t, repo, LockFileName) + + lockErr := errors.New("simulated lock interruption") + sawLock := false + options := opts(repo, source, "b") + options.BeforeMutation = func(decision Decision) error { + if decision.Path == LockFileName { + sawLock = true + return lockErr + } + return nil + } + + _, err := Update(options) + if !errors.Is(err, lockErr) { + t.Fatalf("expected lock mutation error, got %v", err) + } + if !sawLock { + t.Fatal("update never reached the lock mutation") + } + if got := read(t, repo, first); got != "a1\n" { + t.Fatalf("first payload was not restored: %q", got) + } + if got := read(t, repo, second); got != "b1\n" { + t.Fatalf("second payload was not restored: %q", got) + } + if got := read(t, repo, LockFileName); got != lockBefore { + t.Fatal("failed lock mutation changed the lock") + } + assertNoTransactionStaging(t, repo) +} + +func TestTopologyTransitionRollsBackOnLockFailure(t *testing.T) { + for _, test := range []struct { + name string + initialPath string + initialData string + updatedPath string + updatedData string + }{ + {name: "file to directory", initialPath: "memory-bank/dna/topic", initialData: "old file\n", updatedPath: "memory-bank/dna/topic/page.md", updatedData: "new child\n"}, + {name: "directory to file", initialPath: "memory-bank/dna/topic/page.md", initialData: "old child\n", updatedPath: "memory-bank/dna/topic", updatedData: "new file\n"}, + } { + t.Run(test.name, func(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + write(t, source, test.initialPath, test.initialData) + initialize(t, repo, source) + lockBefore := read(t, repo, LockFileName) + if err := os.RemoveAll(filepath.Join(source, "memory-bank", "dna", "topic")); err != nil { + t.Fatal(err) + } + write(t, source, test.updatedPath, test.updatedData) + + interruption := errors.New("simulated lock failure") + options := opts(repo, source, "b") + options.BeforeMutation = func(decision Decision) error { + if decision.Path == LockFileName { + return interruption + } + return nil + } + if _, err := Update(options); !errors.Is(err, interruption) { + t.Fatalf("expected lock failure, got %v", err) + } + if got := read(t, repo, test.initialPath); got != test.initialData { + t.Fatalf("rollback did not restore original topology: %q", got) + } + if got := read(t, repo, LockFileName); got != lockBefore { + t.Fatal("failed topology transition changed the lock") + } + assertNoTransactionStaging(t, repo) + }) + } +} + +func TestTransactionRollbackRemovesCreatedDirectories(t *testing.T) { + repo := t.TempDir() + first := "memory-bank/new/nested/a.md" + second := "memory-bank/trigger.md" + interruption := errors.New("simulated interruption") + mutationCalls := 0 + options := Options{ + RepoRoot: repo, + BeforeMutation: func(Decision) error { + mutationCalls++ + if mutationCalls == 2 { + return interruption + } + return nil + }, + } + mutations := []mutation{ + {decision: Decision{Path: first, Action: Create}, data: []byte("created\n")}, + {decision: Decision{Path: second, Action: Create}, data: []byte("never reached\n")}, + } + + err := applyAtomically(options, mutations) + if !errors.Is(err, interruption) { + t.Fatalf("expected interruption, got %v", err) + } + if _, err := os.Lstat(filepath.Join(repo, "memory-bank")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rollback left transaction-created directories behind: %v", err) + } + assertNoTransactionStaging(t, repo) +} + +func TestTransactionRestoresOriginalWhenInstallRenameFails(t *testing.T) { + repo := t.TempDir() + path := "memory-bank/dna/rule.md" + write(t, repo, path, "one\n") + installErr := errors.New("simulated install failure") + mutations := []mutation{{ + decision: Decision{Path: path, Action: UpdateFile}, + data: []byte("two\n"), + expectedExists: true, + expectedDigest: digest([]byte("one\n")), + }} + ops := transactionOps{ + writeFile: os.WriteFile, + rename: os.Rename, + link: func(oldPath, newPath string) error { + if filepath.Base(filepath.Dir(oldPath)) == "new" { + return installErr + } + return os.Link(oldPath, newPath) + }, + } + + err := applyAtomicallyWithOps(Options{RepoRoot: repo}, mutations, ops) + if !errors.Is(err, installErr) { + t.Fatalf("expected install error, got %v", err) + } + if got := read(t, repo, path); got != "one\n" { + t.Fatalf("failed install did not restore original: %q", got) + } + assertNoTransactionStaging(t, repo) +} + +func TestTransactionPreservesEditMadeToAppliedTargetBeforeRollback(t *testing.T) { + repo := t.TempDir() + first := "memory-bank/dna/a.md" + second := "memory-bank/dna/b.md" + write(t, repo, first, "a1\n") + write(t, repo, second, "b1\n") + interruption := errors.New("simulated interruption") + options := Options{ + RepoRoot: repo, + BeforeMutation: func(decision Decision) error { + if decision.Path != second { + return nil + } + write(t, repo, first, "concurrent edit\n") + return interruption + }, + } + mutations := []mutation{ + { + decision: Decision{Path: first, Action: UpdateFile}, + data: []byte("a2\n"), + expectedExists: true, + expectedDigest: digest([]byte("a1\n")), + }, + { + decision: Decision{Path: second, Action: UpdateFile}, + data: []byte("b2\n"), + expectedExists: true, + expectedDigest: digest([]byte("b1\n")), + }, + } + + err := applyAtomically(options, mutations) + if !errors.Is(err, interruption) || !strings.Contains(err.Error(), "rollback incomplete") { + t.Fatalf("expected interruption and incomplete rollback, got %v", err) + } + if got := read(t, repo, first); got != "concurrent edit\n" { + t.Fatalf("rollback overwrote a concurrent edit: %q", got) + } + if got := read(t, repo, second); got != "b1\n" { + t.Fatalf("unreached target changed: %q", got) + } + staging, err := filepath.Glob(filepath.Join(repo, ".memory-bank-update-*")) + if err != nil || len(staging) != 1 { + t.Fatalf("expected retained recovery staging, got %v err=%v", staging, err) + } + if got := read(t, staging[0], "old/000000"); got != "a1\n" { + t.Fatalf("recovery staging lost pre-update original: %q", got) + } +} + +func TestTransactionRetainsRecoveryDataOnPanic(t *testing.T) { + repo := t.TempDir() + first := "memory-bank/dna/a.md" + second := "memory-bank/dna/b.md" + write(t, repo, first, "a1\n") + write(t, repo, second, "b1\n") + mutationCalls := 0 + options := Options{ + RepoRoot: repo, + BeforeMutation: func(Decision) error { + mutationCalls++ + if mutationCalls == 2 { + panic("simulated panic") + } + return nil + }, + } + mutations := []mutation{ + {decision: Decision{Path: first, Action: UpdateFile}, data: []byte("a2\n"), expectedExists: true}, + {decision: Decision{Path: second, Action: UpdateFile}, data: []byte("b2\n"), expectedExists: true}, + } + var recovered any + func() { + defer func() { recovered = recover() }() + _ = applyAtomically(options, mutations) + }() + if recovered == nil { + t.Fatal("expected simulated panic") + } + staging, err := filepath.Glob(filepath.Join(repo, ".memory-bank-update-*")) + if err != nil || len(staging) != 1 { + t.Fatalf("panic discarded recovery staging: %v err=%v", staging, err) + } + if got := read(t, staging[0], "old/000000"); got != "a1\n" { + t.Fatalf("panic discarded original payload: %q", got) + } +} + +func TestTransactionRejectsTamperedStagedPayload(t *testing.T) { + repo := t.TempDir() + path := "memory-bank/dna/rule.md" + write(t, repo, path, "one\n") + options := Options{ + RepoRoot: repo, + BeforeMutation: func(Decision) error { + staging, err := filepath.Glob(filepath.Join(repo, ".memory-bank-update-*")) + if err != nil { + return fmt.Errorf("locate staging: %w", err) + } + if len(staging) != 1 { + return fmt.Errorf("locate staging: got %v", staging) + } + return os.WriteFile(filepath.Join(staging[0], "new", "000000"), []byte("tampered\n"), 0o644) + }, + } + mutations := []mutation{{ + decision: Decision{Path: path, Action: UpdateFile}, + data: []byte("two\n"), + expectedExists: true, + expectedDigest: digest([]byte("one\n")), + }} + + err := applyAtomically(options, mutations) + if err == nil || !strings.Contains(err.Error(), "staged payload changed") { + t.Fatalf("expected staged-payload integrity error, got %v", err) + } + if got := read(t, repo, path); got != "one\n" { + t.Fatalf("tampered staged payload replaced original: %q", got) + } + assertNoTransactionStaging(t, repo) +} + +func TestTransactionReportsCleanupFailureAfterCommit(t *testing.T) { + repo := t.TempDir() + path := "memory-bank/dna/rule.md" + write(t, repo, path, "one\n") + cleanupErr := errors.New("simulated cleanup failure") + mutations := []mutation{{ + decision: Decision{Path: path, Action: UpdateFile}, + data: []byte("two\n"), + expectedExists: true, + expectedDigest: digest([]byte("one\n")), + }} + ops := osTransactionOps + ops.removeAll = func(string) error { return cleanupErr } + + err := applyAtomicallyWithOps(Options{RepoRoot: repo}, mutations, ops) + if !errors.Is(err, cleanupErr) || !strings.Contains(err.Error(), "update committed") { + t.Fatalf("expected explicit committed-cleanup error, got %v", err) + } + var committed *committedError + if !errors.As(err, &committed) { + t.Fatalf("cleanup error did not preserve committed outcome: %v", err) + } + if got := read(t, repo, path); got != "two\n" { + t.Fatalf("cleanup failure changed committed payload: %q", got) + } + staging, globErr := filepath.Glob(filepath.Join(repo, ".memory-bank-update-*")) + if globErr != nil || len(staging) != 1 { + t.Fatalf("expected retained staging, got %v err=%v", staging, globErr) + } +} + +func TestTransactionDoesNotReplaceDestinationThatAppearsDuringInstall(t *testing.T) { + repo := t.TempDir() + path := "memory-bank/dna/rule.md" + write(t, repo, path, "one\n") + mutations := []mutation{{ + decision: Decision{Path: path, Action: UpdateFile}, + data: []byte("two\n"), + expectedExists: true, + expectedDigest: digest([]byte("one\n")), + }} + ops := osTransactionOps + ops.link = func(oldPath, newPath string) error { + if filepath.Base(filepath.Dir(oldPath)) == "new" { + if err := os.WriteFile(newPath, []byte("concurrent\n"), 0o644); err != nil { + return err + } + } + return os.Link(oldPath, newPath) + } + + err := applyAtomicallyWithOps(Options{RepoRoot: repo}, mutations, ops) + if err == nil || !strings.Contains(err.Error(), "rollback incomplete") { + t.Fatalf("expected no-clobber install failure, got %v", err) + } + if got := read(t, repo, path); got != "concurrent\n" { + t.Fatalf("install replaced concurrently-created destination: %q", got) + } + staging, globErr := filepath.Glob(filepath.Join(repo, ".memory-bank-update-*")) + if globErr != nil || len(staging) != 1 { + t.Fatalf("expected retained recovery staging, got %v err=%v", staging, globErr) + } + if got := read(t, staging[0], "old/000000"); got != "one\n" { + t.Fatalf("recovery staging lost original payload: %q", got) + } +} + +func TestLockCommitRejectsAndRestoresChangedOriginalBackup(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/dna/rule.md" + write(t, source, path, "one\n") + initialize(t, repo, source) + write(t, source, path, "two\n") + lockBefore := read(t, repo, LockFileName) + + options := opts(repo, source, "b") + options.BeforeMutation = func(decision Decision) error { + if decision.Path != LockFileName { + return nil + } + staging, err := filepath.Glob(filepath.Join(repo, ".memory-bank-update-*")) + if err != nil { + return err + } + if len(staging) != 1 { + return fmt.Errorf("expected one staging directory, got %v", staging) + } + return os.WriteFile(filepath.Join(staging[0], "old", "000000"), []byte("late drift\n"), 0o644) + } + + _, err := Update(options) + if err == nil || !strings.Contains(err.Error(), "backup content changed") { + t.Fatalf("expected changed-backup error, got %v", err) + } + if got := read(t, repo, path); got != "late drift\n" { + t.Fatalf("rollback lost the latest downstream bytes: %q", got) + } + if got := read(t, repo, LockFileName); got != lockBefore { + t.Fatal("lock committed after the original backup changed") + } + assertNoTransactionStaging(t, repo) +} + +func assertNoTransactionStaging(t *testing.T, repo string) { + t.Helper() + staging, err := filepath.Glob(filepath.Join(repo, ".memory-bank-update-*")) + if err != nil { + t.Fatal(err) + } + if len(staging) != 0 { + t.Fatalf("transaction staging was not cleaned up: %v", staging) + } +} diff --git a/internal/ownership/types.go b/internal/ownership/types.go new file mode 100644 index 0000000..ee4eda6 --- /dev/null +++ b/internal/ownership/types.go @@ -0,0 +1,83 @@ +// Package ownership implements the versioned Memory Bank ownership and update contract. +package ownership + +import "time" + +const ( + LockFileName = ".memory-bank.lock.json" + CurrentSchemaVersion = 1 + ReportFormatVersion = 1 +) + +type Class string + +const ( + Managed Class = "managed" + Adapted Class = "adapted" + UserOwned Class = "user-owned" + Generated Class = "generated" +) + +type Template struct { + Version string `json:"version"` + SourceRef string `json:"source_ref"` +} + +type UpdateRecord struct { + Version string `json:"version"` + At time.Time `json:"at"` +} + +type File struct { + Ownership Class `json:"ownership"` + BaseDigest string `json:"base_digest,omitempty"` + PayloadDigest string `json:"payload_digest,omitempty"` + BaseMode string `json:"base_mode,omitempty"` + PayloadMode string `json:"payload_mode,omitempty"` +} + +type Lock struct { + SchemaVersion int `json:"schema_version"` + Template Template `json:"template"` + LastUpdate UpdateRecord `json:"last_update"` + Files map[string]File `json:"files"` +} + +type Action string + +const ( + Create Action = "create" + UpdateFile Action = "update" + Preserve Action = "preserve" + Conflict Action = "conflict" + Delete Action = "delete" +) + +type Decision struct { + Path string `json:"path"` + Ownership Class `json:"ownership"` + Action Action `json:"action"` + Reason string `json:"reason"` +} + +type Report struct { + FormatVersion int `json:"format_version"` + DryRun bool `json:"dry_run"` + Applied bool `json:"applied"` + Decisions []Decision `json:"decisions"` + ConflictCount int `json:"conflict_count"` +} + +type Options struct { + RepoRoot string + SourceRoot string + TemplateVersion string + SourceRef string + DryRun bool + Now func() time.Time + // verifySource is replaced by unit tests that use synthetic source trees. + // CLI callers always use the Git-backed provenance verifier. + verifySource func(string, string) error + // BeforeMutation is used by tests after staging to simulate an interrupted update. + BeforeMutation func(Decision) error +} diff --git a/internal/ownership/update.go b/internal/ownership/update.go new file mode 100644 index 0000000..c649e41 --- /dev/null +++ b/internal/ownership/update.go @@ -0,0 +1,1093 @@ +package ownership + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "reflect" + "regexp" + "runtime" + "sort" + "strings" + "time" +) + +type payload struct { + data []byte + digest string + mode string +} + +type mutation struct { + decision Decision + data []byte + mode fs.FileMode + expectedExists bool + expectedDigest string + expectedMode string + preconditions []destinationPrecondition + topology *topologySnapshot + topologyReplaced bool +} + +type destinationPrecondition struct { + path string + digest string + mode string +} + +var immutableRefPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}([0-9a-fA-F]{24})?$`) + +// Init creates a lock and safely installs missing template files. +func Init(options Options) (Report, error) { + repo, err := pinRepoRoot(options.RepoRoot) + if err != nil { + return Report{}, err + } + options.RepoRoot = repo.root + if _, exists, _, err := readLockSnapshot(repo); err != nil { + return Report{}, err + } else if exists { + return Report{}, fmt.Errorf("%s already exists; use memory-bank update", LockFileName) + } + return run(options, Lock{}, false, repo, "") +} + +// Update applies a source template against an existing lock. +func Update(options Options) (Report, error) { + repo, err := pinRepoRoot(options.RepoRoot) + if err != nil { + return Report{}, err + } + options.RepoRoot = repo.root + lock, exists, lockDigest, err := readLockSnapshot(repo) + if err != nil { + return Report{}, err + } + if !exists { + return Report{}, ErrLockNotFound + } + return run(options, lock, true, repo, lockDigest) +} + +func run(options Options, old Lock, hasLock bool, repo pinnedRepo, lockDigest string) (Report, error) { + if options.RepoRoot == "" || options.SourceRoot == "" || options.TemplateVersion == "" || options.SourceRef == "" { + return Report{}, errors.New("repo root, source root, template version, and immutable source ref are required") + } + if !immutableRefPattern.MatchString(options.SourceRef) { + return Report{}, errors.New("source ref must be a full 40- or 64-character hexadecimal commit ID") + } + pinnedSource, err := pinSourceRoot(options.SourceRoot) + if err != nil { + return Report{}, err + } + if err := rejectOverlappingRoots(repo, pinnedSource); err != nil { + return Report{}, err + } + verifySource := verifySourceCheckout + if options.verifySource != nil { + verifySource = options.verifySource + } + if err := verifySource(pinnedSource.root, options.SourceRef); err != nil { + return Report{}, err + } + options.SourceRoot = pinnedSource.root + var source map[string]payload + if options.verifySource == nil { + source, err = readGitSource(pinnedSource, options.SourceRef) + } else { + source, err = readSource(pinnedSource) + } + if err != nil { + return Report{}, err + } + if err := verifySource(pinnedSource.root, options.SourceRef); err != nil { + return Report{}, fmt.Errorf("source checkout changed while reading template: %w", err) + } + mutations, decisions, next, err := buildPlan(repo, source, old, hasLock) + if err != nil { + return Report{}, err + } + report := Report{FormatVersion: ReportFormatVersion, DryRun: options.DryRun, Decisions: decisions} + for _, decision := range decisions { + if decision.Action == Conflict { + report.ConflictCount++ + } + } + if report.ConflictCount > 0 || options.DryRun { + return report, nil + } + template := Template{Version: options.TemplateVersion, SourceRef: options.SourceRef} + needsLockWrite := !hasLock || len(mutations) > 0 || old.SchemaVersion != CurrentSchemaVersion || old.Template != template + if !needsLockWrite { + return report, nil + } + now := time.Now + if options.Now != nil { + now = options.Now + } + next.SchemaVersion = CurrentSchemaVersion + next.Template = template + next.LastUpdate = UpdateRecord{Version: options.TemplateVersion, At: now().UTC()} + lockData, err := marshalLock(next) + if err != nil { + return Report{}, err + } + mutations = append(mutations, mutation{ + decision: Decision{Path: LockFileName, Action: UpdateFile, Reason: "record successful update"}, + data: lockData, + expectedExists: hasLock, + expectedDigest: lockDigest, + preconditions: lockPreconditions(next), + }) + if err := applyAtomicallyPinned(options, mutations, repo); err != nil { + var committed *committedError + if errors.As(err, &committed) { + report.Applied = true + return report, err + } + return Report{}, err + } + report.Applied = true + return report, nil +} + +func readSource(source pinnedSource) (map[string]payload, error) { + if err := inspectSourceRoot(source); err != nil { + return nil, err + } + root := source.root + memoryBankRoot := filepath.Join(root, "memory-bank") + result := make(map[string]payload) + err := filepath.WalkDir(memoryBankRoot, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("template source contains unsupported symlink: %s", path) + } + if entry.IsDir() { + return nil + } + if !entry.Type().IsRegular() { + return fmt.Errorf("template source contains unsupported file: %s", path) + } + info, err := entry.Info() + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + result[filepath.ToSlash(relative)] = payload{data: data, digest: digest(data), mode: gitMode(info.Mode().Perm())} + return nil + }) + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("template source has no memory-bank directory: %s", root) + } + if err != nil { + return nil, err + } + if err := inspectSourceRoot(source); err != nil { + return nil, err + } + return result, nil +} + +func readGitSource(source pinnedSource, ref string) (map[string]payload, error) { + if err := inspectSourceRoot(source); err != nil { + return nil, err + } + tree, err := gitBytes(source.root, "ls-tree", "-rz", "--full-tree", ref, "--", "memory-bank") + if err != nil { + return nil, fmt.Errorf("read pinned source tree: %w", err) + } + result := make(map[string]payload) + for _, record := range strings.Split(string(tree), "\x00") { + if record == "" { + continue + } + header, filePath, found := strings.Cut(record, "\t") + fields := strings.Fields(header) + if !found || len(fields) != 3 || fields[1] != "blob" || fields[0] != "100644" && fields[0] != "100755" { + return nil, fmt.Errorf("read pinned source tree: unsupported entry %q", filePath) + } + data, err := gitBytes(source.root, "cat-file", "blob", fields[2]) + if err != nil { + return nil, fmt.Errorf("read pinned source file %q: %w", filePath, err) + } + result[filePath] = payload{data: data, digest: digest(data), mode: fields[0]} + } + if len(result) == 0 { + return nil, errors.New("template source has no memory-bank payload") + } + if err := inspectSourceRoot(source); err != nil { + return nil, err + } + return result, nil +} + +func gitMode(mode fs.FileMode) string { + if mode&0o111 != 0 { + return "100755" + } + return "100644" +} + +func fileMode(mode string) fs.FileMode { + if mode == "100755" { + return 0o755 + } + return 0o644 +} + +func observedMode(mode fs.FileMode) string { + if runtime.GOOS == "windows" { + return "" + } + return gitMode(mode) +} + +func modeMatches(observed, expected string) bool { + return observed == "" || observed == expected +} + +func buildPlan(repo pinnedRepo, source map[string]payload, old Lock, hasLock bool) ([]mutation, []Decision, Lock, error) { + if _, err := inspectRepoRoot(repo.root, repo.info); err != nil { + return nil, nil, Lock{}, err + } + next := Lock{Files: make(map[string]File)} + var removalMutations []mutation + var removalDecisions []Decision + cleanRemovals := make(map[string]string) + removalMutationIndex := make(map[string]int) + removed := make([]string, 0) + for path := range old.Files { + if _, exists := source[path]; !exists { + removed = append(removed, path) + } + } + sort.Strings(removed) + for _, path := range removed { + prior := old.Files[path] + currentDigest, exists, err := digestDestinationFile(repo, path) + if err != nil { + return nil, nil, Lock{}, err + } + decision := Decision{Path: path, Ownership: prior.Ownership} + currentMode := "" + if exists { + _, info, _, inspectErr := inspectDestination(repo, path) + if inspectErr != nil { + return nil, nil, Lock{}, inspectErr + } + currentMode = observedMode(info.Mode().Perm()) + } + switch { + case !exists: + decision.Action, decision.Reason = Preserve, "file already absent" + case prior.Ownership == Managed && currentDigest == prior.PayloadDigest && destinationModeMatches(repo, path, prior.PayloadMode): + decision.Action, decision.Reason = Delete, "unmodified managed file was removed upstream" + cleanRemovals[path] = currentDigest + removalMutationIndex[path] = len(removalMutations) + removalMutations = append(removalMutations, mutation{decision: decision, expectedExists: true, expectedDigest: currentDigest, expectedMode: currentMode}) + case prior.Ownership == Generated: + decision.Action, decision.Reason = Delete, "generated file was removed upstream" + cleanRemovals[path] = currentDigest + removalMutationIndex[path] = len(removalMutations) + removalMutations = append(removalMutations, mutation{decision: decision, expectedExists: true, expectedDigest: currentDigest, expectedMode: currentMode}) + case prior.Ownership == Managed: + decision.Action, decision.Reason = Conflict, "removed managed file has downstream drift" + next.Files[path] = prior + default: + decision.Action, decision.Reason = Preserve, "downstream-owned file is never deleted" + next.Files[path] = prior + } + removalDecisions = append(removalDecisions, decision) + } + sort.SliceStable(removalMutations, func(i, j int) bool { + return strings.Count(removalMutations[i].decision.Path, "/") > strings.Count(removalMutations[j].decision.Path, "/") + }) + for index := range removalMutations { + removalMutationIndex[removalMutations[index].decision.Path] = index + } + + var sourceMutations []mutation + var sourceDecisions []Decision + paths := make([]string, 0, len(source)) + for path := range source { + paths = append(paths, path) + } + sort.Strings(paths) + for _, path := range paths { + incoming := source[path] + class := Classify(path) + prior, tracked := old.Files[path] + currentDigest, exists, topology, err := inspectDestinationForPlan(repo, path, cleanRemovals) + if err != nil { + return nil, nil, Lock{}, err + } + currentMode := "" + if exists { + _, info, _, inspectErr := inspectDestination(repo, path) + if inspectErr != nil { + return nil, nil, Lock{}, inspectErr + } + currentMode = observedMode(info.Mode().Perm()) + } + priorBaseMode, priorPayloadMode := prior.BaseMode, prior.PayloadMode + if priorBaseMode == "" { + priorBaseMode = currentMode + if priorBaseMode == "" { + priorBaseMode = incoming.mode + } + } + if priorPayloadMode == "" { + priorPayloadMode = currentMode + if priorPayloadMode == "" { + priorPayloadMode = incoming.mode + } + } + decision := Decision{Path: path, Ownership: class} + file := File{Ownership: class, BaseDigest: incoming.digest, BaseMode: incoming.mode} + if class == Managed || class == Generated { + file.PayloadDigest = incoming.digest + file.PayloadMode = incoming.mode + } + switch { + case !exists && !tracked: + decision.Action, decision.Reason = Create, "template file is missing" + case !exists && prior.Ownership == Generated: + decision.Action, decision.Reason = UpdateFile, "regenerate deterministic payload" + case !exists && prior.Ownership == Managed: + decision.Action, decision.Reason = Conflict, "managed file has downstream drift" + file = prior + case !exists: + decision.Action, decision.Reason = Conflict, "downstream-owned file was deleted; explicit resolution required" + file = prior + case !hasLock && class == Generated: + if currentDigest == incoming.digest && modeMatches(currentMode, incoming.mode) { + decision.Action, decision.Reason = Preserve, "adopt existing generated payload" + } else { + decision.Action, decision.Reason = UpdateFile, "regenerate deterministic payload" + } + case !hasLock: + if class == Managed && (currentDigest != incoming.digest || !modeMatches(currentMode, incoming.mode)) { + decision.Action, decision.Reason = Conflict, "existing managed file does not match initialization source" + } else { + decision.Action, decision.Reason = Preserve, "adopt existing file without overwriting it" + } + case !tracked: + decision.Action, decision.Reason = Preserve, "untracked existing file is downstream-owned" + file = File{Ownership: UserOwned} + case class == UserOwned || prior.Ownership == UserOwned: + decision.Action, decision.Reason = Preserve, "user-owned files are never overwritten" + file = prior + case class == Generated: + if currentDigest == incoming.digest && modeMatches(currentMode, incoming.mode) { + decision.Action, decision.Reason = Preserve, "generated payload is current" + } else { + decision.Action, decision.Reason = UpdateFile, "regenerate deterministic payload" + } + case class == Managed: + if currentDigest == incoming.digest && modeMatches(currentMode, incoming.mode) { + decision.Action, decision.Reason = Preserve, "managed payload matches incoming template" + } else if currentDigest != prior.PayloadDigest || !modeMatches(currentMode, priorPayloadMode) { + decision.Action, decision.Reason = Conflict, "managed file has downstream drift" + } else if incoming.digest != prior.BaseDigest || incoming.mode != priorBaseMode { + decision.Action, decision.Reason = UpdateFile, "managed template payload changed" + } else { + decision.Action, decision.Reason = Preserve, "managed payload is current" + } + case class == Adapted: + downstreamChanged := currentDigest != prior.BaseDigest || !modeMatches(currentMode, priorBaseMode) + upstreamChanged := incoming.digest != prior.BaseDigest || incoming.mode != priorBaseMode + if currentDigest == incoming.digest && modeMatches(currentMode, incoming.mode) { + decision.Action, decision.Reason = Preserve, "adapted payload matches incoming template base" + } else if downstreamChanged && upstreamChanged { + decision.Action, decision.Reason = Conflict, "adapted file changed both upstream and downstream" + file = prior + } else if upstreamChanged { + decision.Action, decision.Reason = UpdateFile, "unmodified adapted file follows new template base" + } else { + decision.Action, decision.Reason = Preserve, "preserve downstream adaptation" + } + } + decision.Ownership = file.Ownership + if decision.Action == Create || decision.Action == UpdateFile { + sourceMutations = append(sourceMutations, mutation{ + decision: decision, data: incoming.data, mode: fileMode(incoming.mode), expectedExists: exists, expectedDigest: currentDigest, expectedMode: currentMode, topology: topology, + }) + if topology != nil { + for _, prerequisite := range topology.files { + if index, ok := removalMutationIndex[prerequisite.path]; ok { + removalMutations[index].topologyReplaced = true + } + } + } + } + next.Files[path] = file + sourceDecisions = append(sourceDecisions, decision) + } + mutations := append(removalMutations, sourceMutations...) + decisions := append(sourceDecisions, removalDecisions...) + return mutations, decisions, next, nil +} + +func destinationModeMatches(repo pinnedRepo, relative, expected string) bool { + if expected == "" { + return true + } + _, info, exists, err := inspectDestination(repo, relative) + return err == nil && exists && modeMatches(observedMode(info.Mode().Perm()), expected) +} + +func lockPreconditions(lock Lock) []destinationPrecondition { + paths := make([]string, 0, len(lock.Files)) + for path, file := range lock.Files { + if file.Ownership == Managed || file.Ownership == Generated { + paths = append(paths, path) + } + } + sort.Strings(paths) + result := make([]destinationPrecondition, 0, len(paths)) + for _, path := range paths { + result = append(result, destinationPrecondition{path: path, digest: lock.Files[path].PayloadDigest, mode: lock.Files[path].PayloadMode}) + } + return result +} + +type transactionOps struct { + writeFile func(string, []byte, fs.FileMode) error + rename func(string, string) error + link func(string, string) error + removeAll func(string) error + renameToDestination func(pinnedRepo, string, string) error + renameFromDestination func(pinnedRepo, string, string) error + linkToDestination func(pinnedRepo, string, string) error +} + +type committedError struct { + err error +} + +func (err *committedError) Error() string { + return "update committed but " + err.err.Error() +} + +func (err *committedError) Unwrap() error { + return err.err +} + +var osTransactionOps = transactionOps{ + writeFile: os.WriteFile, + rename: os.Rename, + link: os.Link, + removeAll: os.RemoveAll, + renameToDestination: secureRenameToDestination, + renameFromDestination: secureRenameFromDestination, + linkToDestination: secureLinkToDestination, +} + +type stagedMutation struct { + mutation + target string + replacement string + backup string + applied string + originalInfo fs.FileInfo + originalDigest string + replacementInfo fs.FileInfo + replacementDigest string + originalMoved bool + replacementInstalled bool +} + +func applyAtomically(options Options, mutations []mutation) error { + repo, err := pinRepoRoot(options.RepoRoot) + if err != nil { + return err + } + return applyAtomicallyPinnedWithOps(options, mutations, repo, osTransactionOps) +} + +func applyAtomicallyPinned(options Options, mutations []mutation, repo pinnedRepo) error { + return applyAtomicallyPinnedWithOps(options, mutations, repo, osTransactionOps) +} + +// applyAtomicallyWithOps prepares every payload before it mutates a target. +// Existing targets are moved, rather than copied, into same-filesystem staging +// so rollback does not need to allocate space to restore them. +func applyAtomicallyWithOps(options Options, mutations []mutation, ops transactionOps) error { + repo, err := pinRepoRoot(options.RepoRoot) + if err != nil { + return err + } + return applyAtomicallyPinnedWithOps(options, mutations, repo, ops) +} + +func applyAtomicallyPinnedWithOps(options Options, mutations []mutation, repo pinnedRepo, ops transactionOps) (resultErr error) { + if _, err := inspectRepoRoot(repo.root, repo.info); err != nil { + return err + } + if ops.writeFile == nil { + ops.writeFile = os.WriteFile + } + if ops.rename == nil { + ops.rename = os.Rename + } + if ops.link == nil { + ops.link = os.Link + } + repoRoot := repo.root + options.RepoRoot = repoRoot + staging, err := os.MkdirTemp(repoRoot, ".memory-bank-update-") + if err != nil { + return fmt.Errorf("create update staging: %w", err) + } + cleanupStaging := true + commitComplete := false + defer func() { + if !cleanupStaging { + return + } + if _, err := inspectRepoRoot(repo.root, repo.info); err != nil { + cleanupErr := fmt.Errorf("update staging retained at %s because repo root changed: %w", staging, err) + if commitComplete { + resultErr = errors.Join(resultErr, &committedError{err: cleanupErr}) + } else { + resultErr = errors.Join(resultErr, cleanupErr) + } + return + } + removeAll := ops.removeAll + if removeAll == nil { + removeAll = os.RemoveAll + } + if err := removeAll(staging); err != nil { + cleanupErr := fmt.Errorf("update staging retained at %s: %w", staging, err) + if commitComplete { + resultErr = errors.Join(resultErr, &committedError{err: cleanupErr}) + } else { + resultErr = errors.Join(resultErr, cleanupErr) + } + } + }() + + newDirectory := filepath.Join(staging, "new") + oldDirectory := filepath.Join(staging, "old") + appliedDirectory := filepath.Join(staging, "applied") + if err := os.Mkdir(newDirectory, 0o700); err != nil { + return fmt.Errorf("prepare update staging: %w", err) + } + if err := os.Mkdir(oldDirectory, 0o700); err != nil { + return fmt.Errorf("prepare update staging: %w", err) + } + if err := os.Mkdir(appliedDirectory, 0o700); err != nil { + return fmt.Errorf("prepare update staging: %w", err) + } + linkProbeSource := filepath.Join(staging, "link-probe-source") + linkProbeTarget := filepath.Join(staging, "link-probe-target") + if err := os.WriteFile(linkProbeSource, nil, 0o600); err != nil { + return fmt.Errorf("prepare rollback-link probe: %w", err) + } + if err := os.Link(linkProbeSource, linkProbeTarget); err != nil { + return fmt.Errorf("filesystem does not support safe no-clobber updates: %w", err) + } + if err := os.Remove(linkProbeSource); err != nil { + return fmt.Errorf("remove rollback-link probe source: %w", err) + } + if err := os.Remove(linkProbeTarget); err != nil { + return fmt.Errorf("remove rollback-link probe target: %w", err) + } + + staged := make([]stagedMutation, len(mutations)) + for index, item := range mutations { + var target string + var info fs.FileInfo + var exists bool + var err error + if item.topology != nil { + target, _, err = destinationPathLexicalPinned(repo, item.decision.Path) + if err == nil { + err = verifyTopologySnapshot(repo, item.decision.Path, item.topology) + } + } else { + target, info, exists, err = inspectDestination(repo, item.decision.Path) + } + if err != nil { + return fmt.Errorf("prepare %s: %w", item.decision.Path, err) + } + if exists != item.expectedExists { + return fmt.Errorf("prepare %s: destination changed while update was being planned", item.decision.Path) + } + currentDigest := "" + if exists { + var digestExists bool + currentDigest, digestExists, err = digestDestinationFile(repo, item.decision.Path) + if err != nil { + return fmt.Errorf("prepare %s: %w", item.decision.Path, err) + } + if !digestExists || (item.expectedDigest != "" && currentDigest != item.expectedDigest) { + return fmt.Errorf("prepare %s: destination content changed while update was being planned", item.decision.Path) + } + if item.expectedMode != "" && !modeMatches(observedMode(info.Mode().Perm()), item.expectedMode) { + return fmt.Errorf("prepare %s: destination mode changed while update was being planned", item.decision.Path) + } + } + staged[index] = stagedMutation{ + mutation: item, + target: target, + backup: filepath.Join(oldDirectory, fmt.Sprintf("%06d", index)), + applied: filepath.Join(appliedDirectory, fmt.Sprintf("%06d", index)), + originalInfo: info, + originalDigest: currentDigest, + } + if item.decision.Action == Delete { + continue + } + mode := item.mode + if mode == 0 { + mode = 0o644 + } + staged[index].replacement = filepath.Join(newDirectory, fmt.Sprintf("%06d", index)) + if err := ops.writeFile(staged[index].replacement, item.data, mode); err != nil { + return fmt.Errorf("stage %s: %w", item.decision.Path, err) + } + if err := os.Chmod(staged[index].replacement, mode); err != nil { + return fmt.Errorf("stage %s mode: %w", item.decision.Path, err) + } + replacementInfo, replacementDigest, err := inspectRegularFile(staged[index].replacement) + if err != nil { + return fmt.Errorf("stage %s: %w", item.decision.Path, err) + } + if replacementDigest != digest(item.data) { + return fmt.Errorf("stage %s: staged payload content mismatch", item.decision.Path) + } + staged[index].replacementInfo = replacementInfo + staged[index].replacementDigest = replacementDigest + } + + createdDirectories := make([]string, 0) + removedDirectories := make([]removedDirectory, 0) + rollback := func() error { + var rollbackErrors []error + if _, err := inspectRepoRoot(repo.root, repo.info); err != nil { + return fmt.Errorf("restore pinned repo root: %w", err) + } + for index := len(staged) - 1; index >= 0; index-- { + item := &staged[index] + if item.replacementInstalled { + if err := moveInstalledToRecovery(repo, item, ops); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("recover current %s: %w", item.decision.Path, err)) + } + } + } + for index := len(createdDirectories) - 1; index >= 0; index-- { + relative := createdDirectories[index] + if err := secureRemoveDestination(repo, relative, true); err != nil && !errors.Is(err, os.ErrNotExist) { + rollbackErrors = append(rollbackErrors, fmt.Errorf("remove new directory %s: %w", relative, err)) + } + } + for index := len(removedDirectories) - 1; index >= 0; index-- { + directory := removedDirectories[index] + if err := secureMkdirDestination(repo, directory.path, directory.mode); err != nil && !errors.Is(err, os.ErrExist) { + rollbackErrors = append(rollbackErrors, fmt.Errorf("recreate replaced directory %s: %w", directory.path, err)) + } + } + for index := len(staged) - 1; index >= 0; index-- { + item := &staged[index] + if item.originalMoved { + if err := restoreOriginalFromBackup(repo, item, ops); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restore %s: %w", item.decision.Path, err)) + } + } + } + return errors.Join(rollbackErrors...) + } + + fail := func(cause error) error { + rollbackErr := rollback() + if rollbackErr == nil { + cleanupStaging = true + return cause + } + return errors.Join( + cause, + fmt.Errorf("rollback incomplete; recovery data retained at %s: %w", staging, rollbackErr), + ) + } + + // From the first target mutation onward, staging may contain the only copy + // of an original. Unexpected unwinding must retain it for recovery. + cleanupStaging = false + for index := range staged { + item := &staged[index] + if options.BeforeMutation != nil { + if err := options.BeforeMutation(item.decision); err != nil { + return fail(fmt.Errorf("update interrupted before %s: %w", item.decision.Path, err)) + } + } + if _, err := inspectRepoRoot(repo.root, repo.info); err != nil { + return fail(fmt.Errorf("apply %s: %w", item.decision.Path, err)) + } + if item.decision.Path == LockFileName { + for priorIndex := 0; priorIndex < index; priorIndex++ { + prior := &staged[priorIndex] + if !prior.originalMoved && !prior.replacementInstalled { + continue + } + if prior.originalMoved { + if err := verifyOriginalBackup(prior); err != nil { + return fail(fmt.Errorf("apply %s: backup for %s changed: %w", item.decision.Path, prior.decision.Path, err)) + } + } + if !prior.topologyReplaced { + if err := verifyRollbackTarget(repo, prior); err != nil { + return fail(fmt.Errorf("apply %s: previously mutated %s changed: %w", item.decision.Path, prior.decision.Path, err)) + } + } + } + } + for _, precondition := range item.preconditions { + if err := verifyDestinationPrecondition(repo, precondition); err != nil { + return fail(fmt.Errorf("apply %s: verify %s: %w", item.decision.Path, precondition.path, err)) + } + } + if item.topology == nil { + if err := verifyOriginalTarget(repo, item); err != nil { + return fail(fmt.Errorf("apply %s: %w", item.decision.Path, err)) + } + } + if item.expectedExists { + if ops.renameFromDestination != nil && sameOperation(ops.rename, os.Rename) { + if err := ops.renameFromDestination(repo, item.decision.Path, item.backup); err != nil { + return fail(fmt.Errorf("apply %s: move original to staging: %w", item.decision.Path, err)) + } + } else if err := ops.rename(item.target, item.backup); err != nil { + return fail(fmt.Errorf("apply %s: move original to staging: %w", item.decision.Path, err)) + } + item.originalMoved = true + backupInfo, backupDigest, err := inspectRegularFile(item.backup) + if err != nil { + return fail(fmt.Errorf("apply %s: inspect staged original: %w", item.decision.Path, err)) + } + if !os.SameFile(item.originalInfo, backupInfo) { + return fail(fmt.Errorf("apply %s: original identity changed while moving to staging", item.decision.Path)) + } + if backupDigest != item.originalDigest { + item.originalDigest = backupDigest + return fail(fmt.Errorf("apply %s: destination content changed while moving to staging", item.decision.Path)) + } + } + if item.decision.Action == Delete { + continue + } + if item.topology != nil { + if err := prepareTopologyDestination(repo, item.decision.Path, item.topology, &removedDirectories); err != nil { + return fail(fmt.Errorf("apply %s: %w", item.decision.Path, err)) + } + } + if err := ensureDestinationParents(repo, item.decision.Path, &createdDirectories); err != nil { + return fail(fmt.Errorf("apply %s: %w", item.decision.Path, err)) + } + if _, _, nowExists, err := inspectDestination(repo, item.decision.Path); err != nil { + return fail(fmt.Errorf("apply %s: %w", item.decision.Path, err)) + } else if nowExists { + return fail(fmt.Errorf("apply %s: destination appeared during update", item.decision.Path)) + } + if err := verifyStagedReplacement(item); err != nil { + return fail(fmt.Errorf("apply %s: %w", item.decision.Path, err)) + } + if ops.linkToDestination != nil && sameOperation(ops.link, os.Link) { + if err := ops.linkToDestination(repo, item.decision.Path, item.replacement); err != nil { + return fail(fmt.Errorf("apply %s: install staged payload without replacing destination: %w", item.decision.Path, err)) + } + } else if err := ops.link(item.replacement, item.target); err != nil { + return fail(fmt.Errorf("apply %s: install staged payload without replacing destination: %w", item.decision.Path, err)) + } + item.replacementInstalled = true + if err := os.Remove(item.replacement); err != nil { + return fail(fmt.Errorf("apply %s: detach installed payload from staging: %w", item.decision.Path, err)) + } + } + commitComplete = true + cleanupStaging = true + return nil +} + +func inspectDestination(repo pinnedRepo, relative string) (string, fs.FileInfo, bool, error) { + target, err := destinationPathPinned(repo, relative) + if err != nil { + return "", nil, false, err + } + info, err := os.Lstat(target) + if errors.Is(err, os.ErrNotExist) { + return target, nil, false, nil + } + if err != nil { + return "", nil, false, fmt.Errorf("inspect destination path %q: %w", relative, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return "", nil, false, fmt.Errorf("unsafe destination path %q: file is a symlink", relative) + } + if !info.Mode().IsRegular() { + return "", nil, false, fmt.Errorf("unsupported destination file %q", relative) + } + return target, info, true, nil +} + +func ensureDestinationParents(repo pinnedRepo, relative string, created *[]string) error { + return secureEnsureDestinationParents(repo, relative, created) +} + +func verifyOriginalTarget(repo pinnedRepo, item *stagedMutation) error { + _, currentInfo, exists, err := inspectDestination(repo, item.decision.Path) + if err != nil { + return err + } + if exists != item.expectedExists { + return errors.New("destination existence changed after staging") + } + if !exists { + return nil + } + if !os.SameFile(item.originalInfo, currentInfo) { + return errors.New("destination identity changed after staging") + } + if item.expectedMode != "" && !modeMatches(observedMode(currentInfo.Mode().Perm()), item.expectedMode) { + return errors.New("destination mode changed after staging") + } + currentDigest, digestExists, err := digestDestinationFile(repo, item.decision.Path) + if err != nil { + return err + } + if !digestExists || currentDigest != item.originalDigest { + return errors.New("destination content changed after staging") + } + return nil +} + +func verifyDestinationPrecondition(repo pinnedRepo, precondition destinationPrecondition) error { + _, info, exists, err := inspectDestination(repo, precondition.path) + if err != nil { + return err + } + if !exists { + return errors.New("managed payload is missing") + } + readInfo, data, err := secureReadDestination(repo, precondition.path) + if err != nil { + return err + } + if !os.SameFile(info, readInfo) { + return errors.New("managed payload identity changed before lock commit") + } + currentDigest := digest(data) + if currentDigest != precondition.digest { + return errors.New("managed payload content changed before lock commit") + } + if precondition.mode != "" && !modeMatches(observedMode(info.Mode().Perm()), precondition.mode) { + return errors.New("managed payload mode changed before lock commit") + } + return nil +} + +func verifyStagedReplacement(item *stagedMutation) error { + info, payloadDigest, err := inspectRegularFile(item.replacement) + if err != nil { + return fmt.Errorf("inspect staged payload: %w", err) + } + if !os.SameFile(item.replacementInfo, info) || payloadDigest != item.replacementDigest { + return errors.New("staged payload changed before installation") + } + if !modeMatches(observedMode(info.Mode().Perm()), gitMode(item.mode)) { + return errors.New("staged payload mode changed before installation") + } + return nil +} + +func verifyInstalledTarget(repo pinnedRepo, item *stagedMutation) error { + _, currentInfo, exists, err := inspectDestination(repo, item.decision.Path) + if err != nil { + return err + } + if !exists { + return errors.New("installed payload is missing") + } + if !os.SameFile(item.replacementInfo, currentInfo) { + return errors.New("installed payload identity changed") + } + if !modeMatches(observedMode(currentInfo.Mode().Perm()), gitMode(item.mode)) { + return errors.New("installed payload mode changed") + } + currentDigest, digestExists, err := digestDestinationFile(repo, item.decision.Path) + if err != nil { + return err + } + if !digestExists || currentDigest != item.replacementDigest { + return errors.New("installed payload content changed") + } + return nil +} + +func verifyRollbackTarget(repo pinnedRepo, item *stagedMutation) error { + if item.replacementInstalled { + return verifyInstalledTarget(repo, item) + } + _, _, exists, err := inspectDestination(repo, item.decision.Path) + if err != nil { + return err + } + if exists { + return errors.New("destination appeared after original was staged") + } + return nil +} + +func moveInstalledToRecovery(repo pinnedRepo, item *stagedMutation, ops transactionOps) error { + _, _, exists, err := inspectDestination(repo, item.decision.Path) + if err != nil { + return err + } + if !exists { + return errors.New("installed payload is missing") + } + var moveErr error + if ops.renameFromDestination != nil && sameOperation(ops.rename, os.Rename) { + moveErr = ops.renameFromDestination(repo, item.decision.Path, item.applied) + } else { + moveErr = ops.rename(item.target, item.applied) + } + if moveErr != nil { + return fmt.Errorf("move installed payload to recovery: %w", moveErr) + } + movedInfo, movedDigest, err := inspectRegularFile(item.applied) + if err != nil { + return fmt.Errorf("inspect recovered payload: %w", err) + } + if os.SameFile(item.replacementInfo, movedInfo) && movedDigest == item.replacementDigest { + return nil + } + if err := linkToDestination(repo, item, item.applied, ops); err != nil { + return errors.Join( + errors.New("installed payload changed before rollback"), + fmt.Errorf("preserve changed payload: %w", err), + ) + } + return errors.New("installed payload changed before rollback; current content was preserved") +} + +func restoreOriginalFromBackup(repo pinnedRepo, item *stagedMutation, ops transactionOps) error { + backupInfo, backupDigest, err := inspectRegularFile(item.backup) + if err != nil { + return fmt.Errorf("inspect backup: %w", err) + } + if !os.SameFile(item.originalInfo, backupInfo) || backupDigest != item.originalDigest { + return errors.New("backup changed during update") + } + _, _, exists, err := inspectDestination(repo, item.decision.Path) + if err != nil { + return err + } + if exists { + return errors.New("destination appeared before original could be restored") + } + if err := linkToDestination(repo, item, item.backup, ops); err != nil { + return fmt.Errorf("restore original without replacing destination: %w", err) + } + currentInfo, data, err := secureReadDestination(repo, item.decision.Path) + if err != nil { + return fmt.Errorf("verify restored original: %w", err) + } + currentDigest := digest(data) + if !os.SameFile(item.originalInfo, currentInfo) || currentDigest != item.originalDigest { + return errors.New("restored original failed integrity verification") + } + return nil +} + +func linkToDestination(repo pinnedRepo, item *stagedMutation, source string, ops transactionOps) error { + if ops.linkToDestination != nil && sameOperation(ops.link, os.Link) { + return ops.linkToDestination(repo, item.decision.Path, source) + } + return ops.link(source, item.target) +} + +func sameOperation(left, right any) bool { + return reflect.ValueOf(left).Pointer() == reflect.ValueOf(right).Pointer() +} + +func verifyOriginalBackup(item *stagedMutation) error { + backupInfo, backupDigest, err := inspectRegularFile(item.backup) + if err != nil { + return fmt.Errorf("inspect backup: %w", err) + } + if !os.SameFile(item.originalInfo, backupInfo) { + return errors.New("backup identity changed during update") + } + if backupDigest != item.originalDigest { + item.originalDigest = backupDigest + return errors.New("backup content changed during update") + } + return nil +} + +func inspectRegularFile(path string) (fs.FileInfo, string, error) { + info, data, err := readRegularFile(path) + if err != nil { + return nil, "", err + } + return info, digest(data), nil +} + +func readRegularFile(path string) (fs.FileInfo, []byte, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, nil, fmt.Errorf("unsupported file: %s", path) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, nil, err + } + after, err := os.Lstat(path) + if err != nil { + return nil, nil, err + } + if after.Mode()&os.ModeSymlink != 0 || !after.Mode().IsRegular() || !os.SameFile(info, after) { + return nil, nil, fmt.Errorf("file changed while reading: %s", path) + } + return after, data, nil +} + +func digestDestinationFile(repo pinnedRepo, relative string) (string, bool, error) { + _, _, exists, err := inspectDestination(repo, relative) + if err != nil { + return "", false, err + } + if !exists { + return "", false, nil + } + _, data, err := secureReadDestination(repo, relative) + if err != nil { + return "", false, err + } + return digest(data), true, nil +} + +func digest(data []byte) string { + sum := sha256.Sum256(data) + return "sha256:" + strings.ToLower(hex.EncodeToString(sum[:])) +} diff --git a/internal/ownership/update_test.go b/internal/ownership/update_test.go new file mode 100644 index 0000000..a1d2165 --- /dev/null +++ b/internal/ownership/update_test.go @@ -0,0 +1,260 @@ +package ownership + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +var fixedTime = time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC) + +func write(t *testing.T, root, path, contents string) { + t.Helper() + target := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } +} + +func read(t *testing.T, root, path string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func opts(repo, source, version string) Options { + return Options{ + RepoRoot: repo, SourceRoot: source, TemplateVersion: version, + SourceRef: strings.Repeat(version, 40/len(version)+1)[:40], Now: func() time.Time { return fixedTime }, + verifySource: func(string, string) error { return nil }, + } +} + +func initialize(t *testing.T, repo, source string) { + t.Helper() + report, err := Init(opts(repo, source, "a")) + if err != nil || !report.Applied || report.ConflictCount != 0 { + t.Fatalf("init failed: report=%#v err=%v", report, err) + } +} + +func decisionFor(t *testing.T, report Report, path string) Decision { + t.Helper() + for _, decision := range report.Decisions { + if decision.Path == path { + return decision + } + } + t.Fatalf("no decision for %s: %#v", path, report.Decisions) + return Decision{} +} + +func TestCleanUpdateAndRepeatedUpdateAreIdempotent(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/dna/rule.md" + write(t, source, path, "one\n") + initialize(t, repo, source) + write(t, source, path, "two\n") + + report, err := Update(opts(repo, source, "b")) + if err != nil || !report.Applied || decisionFor(t, report, path).Action != UpdateFile { + t.Fatalf("clean update failed: report=%#v err=%v", report, err) + } + if got := read(t, repo, path); got != "two\n" { + t.Fatalf("unexpected payload: %q", got) + } + lockBefore := read(t, repo, LockFileName) + report, err = Update(opts(repo, source, "b")) + if err != nil || report.Applied || decisionFor(t, report, path).Action != Preserve { + t.Fatalf("idempotent update failed: report=%#v err=%v", report, err) + } + if lockAfter := read(t, repo, LockFileName); lockAfter != lockBefore { + t.Fatal("no-op update rewrote the lock") + } +} + +func TestExecutableModeIsInstalledAndUpdated(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not expose Unix executable permission bits") + } + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/flows/tool.md" + write(t, source, path, "tool\n") + if err := os.Chmod(filepath.Join(source, filepath.FromSlash(path)), 0o755); err != nil { + t.Fatal(err) + } + initialize(t, repo, source) + assertMode := func(want os.FileMode) { + t.Helper() + info, err := os.Stat(filepath.Join(repo, filepath.FromSlash(path))) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != want { + t.Fatalf("unexpected installed mode: got %04o want %04o", got, want) + } + } + assertMode(0o755) + + if err := os.Chmod(filepath.Join(source, filepath.FromSlash(path)), 0o644); err != nil { + t.Fatal(err) + } + report, err := Update(opts(repo, source, "b")) + if err != nil || !report.Applied || decisionFor(t, report, path).Action != UpdateFile { + t.Fatalf("mode-only update failed: report=%#v err=%v", report, err) + } + assertMode(0o644) + lock, _, err := ReadLock(repo) + if err != nil { + t.Fatal(err) + } + if got := lock.Files[path]; got.BaseMode != "100644" || got.PayloadMode != "100644" { + t.Fatalf("mode contract was not recorded: %#v", got) + } +} + +func TestAdaptedCustomizationIsPreserved(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/domain/model.md" + write(t, source, path, "template\n") + initialize(t, repo, source) + write(t, repo, path, "project model\n") + + report, err := Update(opts(repo, source, "a")) + if err != nil || report.Applied || decisionFor(t, report, path).Action != Preserve { + t.Fatalf("customization was not preserved: report=%#v err=%v", report, err) + } + if got := read(t, repo, path); got != "project model\n" { + t.Fatalf("adapted file was overwritten: %q", got) + } +} + +func TestAdaptedUpstreamAndDownstreamChangesConflictWithoutPartialApply(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + adapted := "memory-bank/domain/model.md" + managed := "memory-bank/dna/rule.md" + write(t, source, adapted, "base\n") + write(t, source, managed, "managed v1\n") + initialize(t, repo, source) + write(t, repo, adapted, "downstream\n") + write(t, source, adapted, "upstream\n") + write(t, source, managed, "managed v2\n") + lockBefore := read(t, repo, LockFileName) + + report, err := Update(opts(repo, source, "b")) + if err != nil || report.Applied || report.ConflictCount != 1 || decisionFor(t, report, adapted).Action != Conflict { + t.Fatalf("expected conflict: report=%#v err=%v", report, err) + } + if got := read(t, repo, managed); got != "managed v1\n" { + t.Fatalf("managed file was partially updated: %q", got) + } + if got := read(t, repo, LockFileName); got != lockBefore { + t.Fatal("conflicting update changed lock") + } +} + +func TestManagedDriftIsDetectedDeterministically(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/flows/feature.md" + write(t, source, path, "base\n") + initialize(t, repo, source) + write(t, repo, path, "local drift\n") + + for index := 0; index < 2; index++ { + report, err := Update(opts(repo, source, "a")) + if err != nil || report.ConflictCount != 1 || decisionFor(t, report, path).Reason != "managed file has downstream drift" { + t.Fatalf("drift run %d: report=%#v err=%v", index, report, err) + } + } +} + +func TestInterruptedUpdateRollsBackTreeAndLock(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + first, second := "memory-bank/dna/a.md", "memory-bank/dna/b.md" + write(t, source, first, "a1\n") + write(t, source, second, "b1\n") + initialize(t, repo, source) + write(t, source, first, "a2\n") + write(t, source, second, "b2\n") + lockBefore := read(t, repo, LockFileName) + options := opts(repo, source, "b") + count := 0 + options.BeforeMutation = func(Decision) error { + count++ + if count == 2 { + return errors.New("simulated interruption") + } + return nil + } + + if _, err := Update(options); err == nil { + t.Fatal("expected interrupted update error") + } + if read(t, repo, first) != "a1\n" || read(t, repo, second) != "b1\n" { + t.Fatal("interrupted update left partial template changes") + } + if got := read(t, repo, LockFileName); got != lockBefore { + t.Fatal("interrupted update changed lock") + } +} + +func TestSchemaZeroIsUpgradedAfterSuccessfulUpdate(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/dna/rule.md" + write(t, source, path, "base\n") + initialize(t, repo, source) + legacy := strings.Replace(read(t, repo, LockFileName), `"schema_version": 1`, `"schema_version": 0`, 1) + write(t, repo, LockFileName, legacy) + + report, err := Update(opts(repo, source, "a")) + if err != nil || !report.Applied { + t.Fatalf("schema upgrade failed: report=%#v err=%v", report, err) + } + lock, exists, err := ReadLock(repo) + if err != nil || !exists || lock.SchemaVersion != CurrentSchemaVersion { + t.Fatalf("unexpected upgraded lock: %#v exists=%v err=%v", lock, exists, err) + } +} + +func TestUserOwnedFileIsNeverOverwrittenOrDeleted(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/features/FT-001/brief.md" + write(t, source, path, "seed\n") + initialize(t, repo, source) + write(t, repo, path, "user document\n") + if err := os.Remove(filepath.Join(source, filepath.FromSlash(path))); err != nil { + t.Fatal(err) + } + report, err := Update(opts(repo, source, "b")) + if err != nil || decisionFor(t, report, path).Action != Preserve || read(t, repo, path) != "user document\n" { + t.Fatalf("user-owned file was not preserved: report=%#v err=%v", report, err) + } +} + +func TestRemovedCleanManagedFileIsDeleted(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + path := "memory-bank/dna/obsolete.md" + write(t, source, path, "obsolete\n") + initialize(t, repo, source) + if err := os.Remove(filepath.Join(source, filepath.FromSlash(path))); err != nil { + t.Fatal(err) + } + + report, err := Update(opts(repo, source, "b")) + if err != nil || !report.Applied || decisionFor(t, report, path).Action != Delete { + t.Fatalf("managed delete failed: report=%#v err=%v", report, err) + } + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(path))); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed file was not deleted: %v", err) + } +} From e333122421bf2673bb3385cf44bb615d848e2d45 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Tue, 21 Jul 2026 18:11:16 +0500 Subject: [PATCH 2/2] refactor: keep ownership lock inside memory-bank --- docs/adoption.md | 6 +++--- docs/memory-bank.md | 2 +- docs/ownership.md | 2 +- docs/schema/memory-bank-lock-v1.schema.json | 2 +- internal/ownership/lock.go | 2 +- internal/ownership/symlink_test.go | 3 +++ internal/ownership/types.go | 2 +- internal/ownership/update.go | 3 +++ internal/ownership/update_test.go | 14 ++++++++++++++ 9 files changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/adoption.md b/docs/adoption.md index 74bcd3e..6f645a5 100644 --- a/docs/adoption.md +++ b/docs/adoption.md @@ -1,6 +1,6 @@ # Внедрение Memory Bank в проект -Этот документ описывает, как подключить Memory Bank к существующему или новому проекту. В downstream-проект устанавливается каталог `memory-bank/`, а рядом создаётся `.memory-bank.lock.json`; dev-инфраструктура этого репозитория (`cmd/`, `.github/`, `.goreleaser.yml`, `go.mod`, `docs/`) не является частью шаблона приложения. Lock нужно коммитить: он хранит версию источника и ownership-границу для безопасных обновлений. +Этот документ описывает, как подключить Memory Bank к существующему или новому проекту. В downstream-проект устанавливается каталог `memory-bank/`, а внутри него создаётся служебный `memory-bank/.lock`; dev-инфраструктура этого репозитория (`cmd/`, `.github/`, `.goreleaser.yml`, `go.mod`, `docs/`) не является частью шаблона приложения. Lock создаёт `memory-bank init`, а не upstream template. Его нужно коммитить: он хранит версию источника и ownership-границу для безопасных обновлений. ## Что копировать @@ -8,7 +8,7 @@ ```text memory-bank/ -.memory-bank.lock.json + .lock ``` Установите CLI и подготовьте локальный checkout шаблона на конкретном commit, затем выполните из корня downstream-проекта: @@ -223,7 +223,7 @@ jobs: Memory Bank считается внедрённым, когда: -- `memory-bank/` и закоммиченный `.memory-bank.lock.json` находятся в корне downstream-проекта; +- `memory-bank/` с закоммиченным служебным `.lock` находится в корне downstream-проекта; - постоянный контекст `product/`, `domain/`, `engineering/` и `ops/` отражает фактические правила проекта или явно помечает пробелы; - агентские инструкции указывают читать `memory-bank/README.md` и governance-ядро; - первая реальная задача прошла через выбранный flow или `Small Change` routing record; diff --git a/docs/memory-bank.md b/docs/memory-bank.md index 0ded098..1a69668 100644 --- a/docs/memory-bank.md +++ b/docs/memory-bank.md @@ -4,7 +4,7 @@ Ownership-контракт, классы файлов и atomic update policy описаны в [отдельном документе](ownership.md). Основные команды: -- `memory-bank init` создаёт `.memory-bank.lock.json` и устанавливает только отсутствующие файлы; +- `memory-bank init` создаёт служебный `memory-bank/.lock` и устанавливает только отсутствующие файлы; - `memory-bank update` строит ownership-aware mutation plan и применяет его только целиком; - `memory-bank lint` проверяет документацию. diff --git a/docs/ownership.md b/docs/ownership.md index 7749658..c31fc8a 100644 --- a/docs/ownership.md +++ b/docs/ownership.md @@ -1,6 +1,6 @@ # Ownership и безопасные обновления -`.memory-bank.lock.json` — repo-local контракт между downstream-проектом и версией шаблона. Файл коммитится вместе с `memory-bank/`. Формальная схема: [`schema/memory-bank-lock-v1.schema.json`](schema/memory-bank-lock-v1.schema.json). +`memory-bank/.lock` — служебный контракт между downstream-проектом и версией шаблона. Файл создаётся командой `memory-bank init` внутри установленного `memory-bank/` и коммитится вместе с ним; из upstream template он не копируется. Формальная схема: [`schema/memory-bank-lock-v1.schema.json`](schema/memory-bank-lock-v1.schema.json). ## Классы владения diff --git a/docs/schema/memory-bank-lock-v1.schema.json b/docs/schema/memory-bank-lock-v1.schema.json index 0851c75..6a4b657 100644 --- a/docs/schema/memory-bank-lock-v1.schema.json +++ b/docs/schema/memory-bank-lock-v1.schema.json @@ -27,7 +27,7 @@ }, "files": { "type": "object", - "propertyNames": { "pattern": "^memory-bank/(?!\\.\\.(?:/|$))(?!.*\\/\\.\\.(?:/|$))[^\\\\]+$" }, + "propertyNames": { "pattern": "^memory-bank/(?!\\.lock$)(?!\\.\\.(?:/|$))(?!.*\\/\\.\\.(?:/|$))[^\\\\]+$" }, "additionalProperties": { "$ref": "#/$defs/file" } } }, diff --git a/internal/ownership/lock.go b/internal/ownership/lock.go index 814f291..86d133d 100644 --- a/internal/ownership/lock.go +++ b/internal/ownership/lock.go @@ -65,7 +65,7 @@ func readLockSnapshot(repo pinnedRepo) (Lock, bool, string, error) { return Lock{}, false, "", fmt.Errorf("invalid last update in %s", LockFileName) } for filePath, file := range lock.Files { - if strings.Contains(filePath, "\\") || path.Clean(filePath) != filePath || !strings.HasPrefix(filePath, "memory-bank/") { + if filePath == LockFileName || strings.Contains(filePath, "\\") || path.Clean(filePath) != filePath || !strings.HasPrefix(filePath, "memory-bank/") { return Lock{}, false, "", fmt.Errorf("invalid path %q in %s", filePath, LockFileName) } switch file.Ownership { diff --git a/internal/ownership/symlink_test.go b/internal/ownership/symlink_test.go index bb765bb..4755d60 100644 --- a/internal/ownership/symlink_test.go +++ b/internal/ownership/symlink_test.go @@ -41,6 +41,9 @@ func TestReadLockRejectsSymlinkLeaf(t *testing.T) { write(t, source, "memory-bank/dna/rule.md", "template\n") initialize(t, lockOwner, source) outsideLock := filepath.Join(lockOwner, LockFileName) + if err := os.MkdirAll(filepath.Join(repo, "memory-bank"), 0o755); err != nil { + t.Fatal(err) + } symlinkForTest(t, outsideLock, filepath.Join(repo, LockFileName)) _, exists, err := ReadLock(repo) diff --git a/internal/ownership/types.go b/internal/ownership/types.go index ee4eda6..5334d06 100644 --- a/internal/ownership/types.go +++ b/internal/ownership/types.go @@ -4,7 +4,7 @@ package ownership import "time" const ( - LockFileName = ".memory-bank.lock.json" + LockFileName = "memory-bank/.lock" CurrentSchemaVersion = 1 ReportFormatVersion = 1 ) diff --git a/internal/ownership/update.go b/internal/ownership/update.go index c649e41..3cb2bdd 100644 --- a/internal/ownership/update.go +++ b/internal/ownership/update.go @@ -265,6 +265,9 @@ func buildPlan(repo pinnedRepo, source map[string]payload, old Lock, hasLock boo if _, err := inspectRepoRoot(repo.root, repo.info); err != nil { return nil, nil, Lock{}, err } + if _, exists := source[LockFileName]; exists { + return nil, nil, Lock{}, fmt.Errorf("template source contains reserved metadata path: %s", LockFileName) + } next := Lock{Files: make(map[string]File)} var removalMutations []mutation var removalDecisions []Decision diff --git a/internal/ownership/update_test.go b/internal/ownership/update_test.go index a1d2165..626615f 100644 --- a/internal/ownership/update_test.go +++ b/internal/ownership/update_test.go @@ -83,6 +83,20 @@ func TestCleanUpdateAndRepeatedUpdateAreIdempotent(t *testing.T) { } } +func TestInitRejectsReservedLockPathInTemplate(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + write(t, source, "memory-bank/dna/rule.md", "template\n") + write(t, source, LockFileName, "not runtime metadata\n") + + report, err := Init(opts(repo, source, "a")) + if err == nil || !strings.Contains(err.Error(), "reserved metadata path") { + t.Fatalf("expected reserved-path error, got report=%#v err=%v", report, err) + } + if _, statErr := os.Lstat(filepath.Join(repo, LockFileName)); !os.IsNotExist(statErr) { + t.Fatalf("failed init created a lock: %v", statErr) + } +} + func TestExecutableModeIsInstalledAndUpdated(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Windows does not expose Unix executable permission bits")