Skip to content

feat(crossseed): allow custom instance link dirs#1574

Open
s0up4200 wants to merge 7 commits intodevelopfrom
feat/instance-link-dir-override
Open

feat(crossseed): allow custom instance link dirs#1574
s0up4200 wants to merge 7 commits intodevelopfrom
feat/instance-link-dir-override

Conversation

@s0up4200
Copy link
Collaborator

@s0up4200 s0up4200 commented Mar 8, 2026

Allow per-instance overrides for by-instance hardlink/reflink directory names, and reuse the same link directory resolution logic for cross-seed + dirscan. Closes #1085.

Summary by CodeRabbit

  • New Features

    • Added optional "Instance directory name" per-instance setting to override folder names used by the by-instance hardlink/reflink preset; validated and persisted.
    • Added "Fallback to regular mode" option to revert to standard cross-seed behavior if hardlink/reflink operations fail.
    • API and UI: instance create/update now accept and display the new instance directory name field.
  • Documentation

    • Updated hardlink/reflink guides and setup steps to describe the new options and usage.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 8, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8141d5c7-9b0a-46aa-a1b7-f3610cd6a399

📥 Commits

Reviewing files that changed from the base of the PR and between 8113329 and 92f5b19.

📒 Files selected for processing (1)
  • internal/api/handlers/torrents_field_test.go

Walkthrough

Adds per-instance hardlink/reflink directory override (linkDirName) end-to-end: DB migration and model/API/schema changes, new internal linkdir utilities for base-dir selection and destination construction, refactors cross-seed/dirscan to use linkdir, updates tests, frontend form, and documentation.

Changes

Cohort / File(s) Summary
Docs
documentation/docs/features/cross-seed/hardlink-mode.md, documentation/docs/features/cross-seed/link-directories.md
Documented new "Instance directory name" / LinkDirName option, added setup/preset guidance and fallback step for hardlink/reflink.
OpenAPI / Swagger
documentation/static/openapi.yaml, internal/web/swagger/openapi.yaml
Added linkDirName: string to public Instance schema and to instance create/update request payloads.
API Handlers
internal/api/handlers/instances.go
Propagates LinkDirName through request/response structs, normalizes input, and passes it into create/update flows.
Database Migrations
internal/database/migrations/067_add_instance_link_dir_name.sql, internal/database/postgres_migrations/068_add_instance_link_dir_name.sql
Added link_dir_name TEXT NOT NULL DEFAULT '' column to instances and updated instances_view to include it.
Models & DB tests
internal/models/instance.go, internal/models/instance_test.go, internal/database/db_test.go, internal/models/*_test.go, internal/models/postgres_bool_args_test.go
Added LinkDirName to Instance and InstanceUpdateParams; validation/normalization, JSON (un)marshal, create/update transactional flows, and tests updated; InstanceStore.Create signature extended to accept linkDirName.
Linkdir package
internal/linkdir/linkdir.go, internal/linkdir/linkdir_test.go
New helpers: EffectiveInstanceDirName, FindMatchingBaseDir, BuildDestDir for validation, base-dir selection (filesystem-aware), and preset-based destination construction; unit tests added.
Cross-seed & Dirscan refactor
internal/services/crossseed/service.go, internal/services/dirscan/inject.go, internal/services/crossseed/hardlink_mode_test.go
Replaced inline base-dir and dest-dir logic with linkdir functions; updated signatures (e.g., buildHardlinkDestDir now returns (string, error)), call sites, and tests.
Frontend
web/src/pages/CrossSeedPage.tsx, web/src/types/index.ts
Added linkDirName to types and per-instance form; UI shows "Instance directory name" input when by-instance preset is selected and persists value on save.
Other tests / call sites
various internal/*_test.go, internal/api/handlers/torrents_download_test.go
Updated many test call sites to match extended InstanceStore.Create signature (additional trailing nil args) and adjusted insert-argument assertions.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant API as "API Handler"
  participant Service
  participant LinkDir as "internal/linkdir"
  participant DB
  participant FS as "Filesystem"

  Client->>API: POST/PUT /api/instances (linkDirName)
  API->>DB: INSERT/UPDATE instance (link_dir_name)
  Client->>API: POST /cross-seed (request)
  API->>Service: start cross-seed
  Service->>LinkDir: FindMatchingBaseDir(configuredDirs, sourcePath)
  LinkDir-->>Service: selectedBaseDir
  Service->>LinkDir: BuildDestDir(baseDir,preset,group,hash,name,files)
  LinkDir-->>Service: destDir
  Service->>FS: ensure destDir exists / create dirs
  Service->>FS: perform hardlink/reflink operations
  Service->>API: return result
  API->>Client: response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

enhancement, cross-seed, hardlink, web, area/backend, area/frontend, documentation

Poem

🐇
I trim the name and hop with care,
A hidden path I gently share.
One clean check, a tidy nest,
Links find home and take their rest. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.21% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(crossseed): allow custom instance link dirs' directly and clearly describes the main change—enabling per-instance customization of hardlink/reflink directory names.
Linked Issues check ✅ Passed The PR fully addresses issue #1085 by implementing per-instance link directory name overrides for hardlink/reflink modes, enabling users to configure custom directory names like 'movies-xseed' and 'series-xseed' per instance.
Out of Scope Changes check ✅ Passed All changes are narrowly scoped to implementing per-instance link directory naming: database schema, API endpoints, storage models, linkdir utilities, and UI form bindings. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/instance-link-dir-override

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
internal/api/handlers/instances.go (2)

637-727: ⚠️ Potential issue | 🟡 Minor

Normalize LinkDirName before storing it.

linkdir.EffectiveInstanceDirName trims the override at use time, but this handler persists req.LinkDirName verbatim. A whitespace-only value is therefore stored and echoed back by the API even though the linkdir logic will ignore it as empty.

Small fix
 	// Validate input
 	if req.Name == "" || req.Host == "" {
 		RespondError(w, http.StatusBadRequest, "Name and host are required")
 		return
 	}
+
+	if req.LinkDirName != nil {
+		trimmed := strings.TrimSpace(*req.LinkDirName)
+		req.LinkDirName = &trimmed
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/handlers/instances.go` around lines 637 - 727, The handler
currently persists req.LinkDirName verbatim which allows whitespace-only values
to be stored; normalize LinkDirName by trimming whitespace (e.g.,
strings.TrimSpace) and treat an empty result as nil/empty before building
models.InstanceUpdateParams so UpdateInstanceRequest.LinkDirName is saved in
normalized form (ensure the normalized value is assigned into
updateParams.LinkDirName and that linkdir.EffectiveInstanceDirName will then
behave consistently).

374-385: ⚠️ Potential issue | 🟠 Major

Wire linkDirName into instance creation too.

The new override is update-only right now. A create payload that includes linkDirName will be silently ignored here, so API clients cannot create an instance with its by-instance link directory configured in a single request.

Suggested direction
 type CreateInstanceRequest struct {
 	Name                     string                             `json:"name"`
 	Host                     string                             `json:"host"`
 	Username                 string                             `json:"username"`
 	Password                 string                             `json:"password"`
 	BasicUsername            *string                            `json:"basicUsername,omitempty"`
 	BasicPassword            *string                            `json:"basicPassword,omitempty"`
 	TLSSkipVerify            bool                               `json:"tlsSkipVerify,omitempty"`
 	HasLocalFilesystemAccess *bool                              `json:"hasLocalFilesystemAccess,omitempty"`
+	LinkDirName              *string                            `json:"linkDirName,omitempty"`
 	ReannounceSettings       *InstanceReannounceSettingsPayload `json:"reannounceSettings,omitempty"`
 }

Then thread that value through InstanceStore.Create(...) (preferred), or apply it immediately after create before building the response.

Also applies to: 591-625

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/handlers/instances.go` around lines 374 - 385, Add a LinkDirName
field to the CreateInstanceRequest struct (json:"linkDirName,omitempty") and
ensure the CreateInstance handler threads that value into instance creation by
passing it into InstanceStore.Create (preferred) or by setting the created
instance's linkDirName immediately after creation and before persisting/building
the response (e.g., in the CreateInstance handler or before
buildInstanceResponse). Update the InstanceStore.Create signature/implementation
to accept and persist the linkDirName, and mirror the same fix for the update
path referenced around the 591-625 change so creates and updates both honor
linkDirName.
internal/services/crossseed/service.go (1)

10557-10561: ⚠️ Potential issue | 🔴 Critical

Verify LinkDirName escape vulnerability—path traversal sequences are not sanitized.

The per-instance LinkDirName override flows through linkdir.EffectiveInstanceDirName (which only trims whitespace) into linkdir.BuildDestDir, where it's passed to pathutil.SanitizePathSegment. However, SanitizePathSegment removes only illegal Windows filesystem characters (<>:"/\|?*) and control characters—it does not filter .. or reject absolute paths. A crafted override like ../../../etc will pass through sanitization and then traverse outside selectedBaseDir when joined via filepath.Join.

Recommend:

  • Reject overrides containing .. or / in EffectiveInstanceDirName, or
  • Validate the final joined path explicitly contains baseDir using filepath.Rel or eval-symlinks pattern (see internal/api/handlers/torrents.go:2644-2646 for reference).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/service.go` around lines 10557 - 10561, The
EffectiveInstanceDirName (from LinkDirName) currently only trims whitespace and
then flows into linkdir.BuildDestDir which uses pathutil.SanitizePathSegment but
does not block path traversal like ".." or absolute paths; update validation so
that EffectiveInstanceDirName is rejected if it contains path separators or
parent-traversal segments (e.g., "/" or "\" or "..") before calling
BuildDestDir, or after joining validate the final destination against
selectedBaseDir (the base dir used in BuildDestDir) by computing filepath.Rel
(or resolving symlinks) and ensuring the resulting path is within
selectedBaseDir; adjust functions LinkDirName/EffectiveInstanceDirName and
linkdir.BuildDestDir to enforce this check and return an error when validation
fails.
🧹 Nitpick comments (3)
internal/models/instance_test.go (1)

154-207: Add one explicit LinkDirName round-trip assertion here.

These fixture updates keep the schema in sync, but the test still never creates/updates/fetches an instance with LinkDirName set. That leaves the new SELECT/Scan/UPDATE wiring unverified. One assertion covering update + get/list would lock the feature in.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/models/instance_test.go` around lines 154 - 207, The test adds
LinkDirName to schema but never verifies it; update the instance_test to
create/update an Instance with LinkDirName set and assert it round-trips via the
repository methods (e.g. create via the existing insert/UpdateInstance method
and fetch via GetInstance/GetInstances or instances_view query); specifically
set a non-empty LinkDirName on the test instance, call the code path that
persists updates (function/methods handling instance creation/update and the
instances_view SELECT/Scan wiring), then assert the fetched Instance.LinkDirName
equals the original value to cover both UPDATE and SELECT/Scan behavior.
internal/linkdir/linkdir_test.go (1)

17-71: Prefer a table-driven suite for these linkdir cases.

These are all small input/output permutations of the same helpers, so a single table-driven test would be easier to extend for additional presets and error cases without duplicating setup.

As per coding guidelines: "Prefer table-driven test cases in Go tests".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/linkdir/linkdir_test.go` around lines 17 - 71, The tests for
EffectiveInstanceDirName, BuildDestDir (both by-instance and by-tracker
variants) and FindMatchingBaseDir should be refactored into one or more
table-driven test(s) instead of multiple separate functions; create a slice of
test cases that include inputs (e.g., instanceName, overrideDir, mode
"by-instance"/"by-tracker", infoHash, title, files) and expected outputs and
iterate over them inside a single TestLinkdirHelpers loop, running t.Run for
each case and using t.Parallel where appropriate; for the FindMatchingBaseDir
case include the temporary directory setup (currently in
TestFindMatchingBaseDir) as part of a specific table entry (or a separate
subtest) so setup/teardown remains correct while avoiding duplicated test
functions, and assert expected errors/results per case using require/assert as
in the originals (referencing EffectiveInstanceDirName, BuildDestDir,
FindMatchingBaseDir).
internal/services/dirscan/inject.go (1)

445-447: Resolve tracker display names lazily.

For by-instance and flat, buildLinkDestDir ignores trackerDisplayName, but this path still parses the announce URL and may call trackerCustomizationStore.List(ctx) on every inject. Guard the lookup behind instance.HardlinkDirPreset == "by-tracker" so the extra work only happens when it can affect destDir.

♻️ Proposed simplification
-	incomingTrackerDomain := crossseed.ParseTorrentAnnounceDomain(req.TorrentBytes)
-	trackerDisplayName := i.resolveTrackerDisplayName(ctx, incomingTrackerDomain, indexerName(req.SearchResult))
+	trackerDisplayName := ""
+	if instance.HardlinkDirPreset == "by-tracker" {
+		incomingTrackerDomain := crossseed.ParseTorrentAnnounceDomain(req.TorrentBytes)
+		trackerDisplayName = i.resolveTrackerDisplayName(ctx, incomingTrackerDomain, indexerName(req.SearchResult))
+	}
 	destDir := buildLinkDestDir(instance, selectedBaseDir, req.ParsedTorrent.InfoHash, req.ParsedTorrent.Name, trackerDisplayName, incomingFiles)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/dirscan/inject.go` around lines 445 - 447, Avoid parsing
announce domain and resolving trackerDisplayName on every inject when it won't
affect destination dir: move the call to
crossseed.ParseTorrentAnnounceDomain(req.TorrentBytes) and
i.resolveTrackerDisplayName(...) behind a conditional that checks
instance.HardlinkDirPreset == "by-tracker"; only compute incomingTrackerDomain
and trackerDisplayName in that branch (so buildLinkDestDir is called with a
nil/empty trackerDisplayName for other presets). This prevents unnecessary calls
such as trackerCustomizationStore.List(ctx) inside i.resolveTrackerDisplayName
when the hardlink preset is not "by-tracker".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/models/instance.go`:
- Around line 594-595: The LinkDirName field is only applied on updates because
InstanceStore.Create and its INSERT omit the link_dir_name column; add
link_dir_name to the Create path so new instances persist the provided value.
Update the InstanceCreateParams/constructor used by InstanceStore.Create (or the
Create signature) to accept LinkDirName, propagate that value into the SQL
INSERT in InstanceStore.Create (include link_dir_name in the column list and
bind the parameter), and ensure the Get/List/db scan logic remains compatible
with nullable string pointers so newly created rows show the provided
LinkDirName immediately (reference symbols: LinkDirName, InstanceUpdateParams,
InstanceStore.Create, Get, List, and the INSERT for link_dir_name).

---

Outside diff comments:
In `@internal/api/handlers/instances.go`:
- Around line 637-727: The handler currently persists req.LinkDirName verbatim
which allows whitespace-only values to be stored; normalize LinkDirName by
trimming whitespace (e.g., strings.TrimSpace) and treat an empty result as
nil/empty before building models.InstanceUpdateParams so
UpdateInstanceRequest.LinkDirName is saved in normalized form (ensure the
normalized value is assigned into updateParams.LinkDirName and that
linkdir.EffectiveInstanceDirName will then behave consistently).
- Around line 374-385: Add a LinkDirName field to the CreateInstanceRequest
struct (json:"linkDirName,omitempty") and ensure the CreateInstance handler
threads that value into instance creation by passing it into
InstanceStore.Create (preferred) or by setting the created instance's
linkDirName immediately after creation and before persisting/building the
response (e.g., in the CreateInstance handler or before buildInstanceResponse).
Update the InstanceStore.Create signature/implementation to accept and persist
the linkDirName, and mirror the same fix for the update path referenced around
the 591-625 change so creates and updates both honor linkDirName.

In `@internal/services/crossseed/service.go`:
- Around line 10557-10561: The EffectiveInstanceDirName (from LinkDirName)
currently only trims whitespace and then flows into linkdir.BuildDestDir which
uses pathutil.SanitizePathSegment but does not block path traversal like ".." or
absolute paths; update validation so that EffectiveInstanceDirName is rejected
if it contains path separators or parent-traversal segments (e.g., "/" or "\" or
"..") before calling BuildDestDir, or after joining validate the final
destination against selectedBaseDir (the base dir used in BuildDestDir) by
computing filepath.Rel (or resolving symlinks) and ensuring the resulting path
is within selectedBaseDir; adjust functions LinkDirName/EffectiveInstanceDirName
and linkdir.BuildDestDir to enforce this check and return an error when
validation fails.

---

Nitpick comments:
In `@internal/linkdir/linkdir_test.go`:
- Around line 17-71: The tests for EffectiveInstanceDirName, BuildDestDir (both
by-instance and by-tracker variants) and FindMatchingBaseDir should be
refactored into one or more table-driven test(s) instead of multiple separate
functions; create a slice of test cases that include inputs (e.g., instanceName,
overrideDir, mode "by-instance"/"by-tracker", infoHash, title, files) and
expected outputs and iterate over them inside a single TestLinkdirHelpers loop,
running t.Run for each case and using t.Parallel where appropriate; for the
FindMatchingBaseDir case include the temporary directory setup (currently in
TestFindMatchingBaseDir) as part of a specific table entry (or a separate
subtest) so setup/teardown remains correct while avoiding duplicated test
functions, and assert expected errors/results per case using require/assert as
in the originals (referencing EffectiveInstanceDirName, BuildDestDir,
FindMatchingBaseDir).

In `@internal/models/instance_test.go`:
- Around line 154-207: The test adds LinkDirName to schema but never verifies
it; update the instance_test to create/update an Instance with LinkDirName set
and assert it round-trips via the repository methods (e.g. create via the
existing insert/UpdateInstance method and fetch via GetInstance/GetInstances or
instances_view query); specifically set a non-empty LinkDirName on the test
instance, call the code path that persists updates (function/methods handling
instance creation/update and the instances_view SELECT/Scan wiring), then assert
the fetched Instance.LinkDirName equals the original value to cover both UPDATE
and SELECT/Scan behavior.

In `@internal/services/dirscan/inject.go`:
- Around line 445-447: Avoid parsing announce domain and resolving
trackerDisplayName on every inject when it won't affect destination dir: move
the call to crossseed.ParseTorrentAnnounceDomain(req.TorrentBytes) and
i.resolveTrackerDisplayName(...) behind a conditional that checks
instance.HardlinkDirPreset == "by-tracker"; only compute incomingTrackerDomain
and trackerDisplayName in that branch (so buildLinkDestDir is called with a
nil/empty trackerDisplayName for other presets). This prevents unnecessary calls
such as trackerCustomizationStore.List(ctx) inside i.resolveTrackerDisplayName
when the hardlink preset is not "by-tracker".

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 657d6f43-a090-48d4-ae3f-508141b4b7a0

📥 Commits

Reviewing files that changed from the base of the PR and between 7aadde7 and 41739e4.

📒 Files selected for processing (16)
  • documentation/docs/features/cross-seed/hardlink-mode.md
  • documentation/docs/features/cross-seed/link-directories.md
  • documentation/static/openapi.yaml
  • internal/api/handlers/instances.go
  • internal/database/migrations/067_add_instance_link_dir_name.sql
  • internal/database/postgres_migrations/068_add_instance_link_dir_name.sql
  • internal/linkdir/linkdir.go
  • internal/linkdir/linkdir_test.go
  • internal/models/instance.go
  • internal/models/instance_test.go
  • internal/services/crossseed/hardlink_mode_test.go
  • internal/services/crossseed/service.go
  • internal/services/dirscan/inject.go
  • internal/web/swagger/openapi.yaml
  • web/src/pages/CrossSeedPage.tsx
  • web/src/types/index.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/api/handlers/instances.go (1)

199-223: ⚠️ Potential issue | 🟡 Minor

Copy FallbackToRegularMode in the cancellation fallback too.

This branch now returns LinkDirName, but it still drops FallbackToRegularMode, so a canceled/timed-out list response can silently report that flag as false even when the instance is configured otherwise.

Suggested fix
 				UseReflinks:              instances[i].UseReflinks,
+				FallbackToRegularMode:    instances[i].FallbackToRegularMode,
 				Connected:                false,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/handlers/instances.go` around lines 199 - 223, The
cancellation/timed-out response builder for InstanceResponse is missing the
FallbackToRegularMode field, so canceled responses can incorrectly show it as
false; update the fallback branch that constructs InstanceResponse (the same
place setting LinkDirName, Connected, HasDecryptionError, ConnectionStatus,
etc.) to also copy FallbackToRegularMode from the model
(instances[i].FallbackToRegularMode) into the response struct so the flag
mirrors the real instance configuration.
internal/models/instance.go (1)

360-392: ⚠️ Potential issue | 🟠 Major

Reject invalid link_dir_name on write.

Create and Update persist the raw override as-is. A value like ../xseed or movies/4k will save successfully here, then fail later when internal/linkdir/linkdir.go validates the name during dirscan/cross-seed path construction. Validate this before INSERT/UPDATE so the request fails at save time instead of storing unusable state.

Also applies to: 724-727

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/models/instance.go` around lines 360 - 392, Create and Update are
persisting raw link_dir_name values that may be invalid (e.g., "../xseed" or
"movies/4k"); before executing the INSERT/UPDATE in internal/models/instance.go
(the block that builds linkDirValue and calls tx.QueryRowContext), validate
linkDirName when non-nil by calling the linkdir package's validation function
(e.g., linkdir.ValidateName or the appropriate validator in
internal/linkdir/linkdir.go) and return a descriptive error if validation fails;
apply the same pre-insert/pre-update check in both Create and Update (also the
code paths around the other mentioned lines) so invalid names are rejected at
save time instead of being stored.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@internal/api/handlers/instances.go`:
- Around line 199-223: The cancellation/timed-out response builder for
InstanceResponse is missing the FallbackToRegularMode field, so canceled
responses can incorrectly show it as false; update the fallback branch that
constructs InstanceResponse (the same place setting LinkDirName, Connected,
HasDecryptionError, ConnectionStatus, etc.) to also copy FallbackToRegularMode
from the model (instances[i].FallbackToRegularMode) into the response struct so
the flag mirrors the real instance configuration.

In `@internal/models/instance.go`:
- Around line 360-392: Create and Update are persisting raw link_dir_name values
that may be invalid (e.g., "../xseed" or "movies/4k"); before executing the
INSERT/UPDATE in internal/models/instance.go (the block that builds linkDirValue
and calls tx.QueryRowContext), validate linkDirName when non-nil by calling the
linkdir package's validation function (e.g., linkdir.ValidateName or the
appropriate validator in internal/linkdir/linkdir.go) and return a descriptive
error if validation fails; apply the same pre-insert/pre-update check in both
Create and Update (also the code paths around the other mentioned lines) so
invalid names are rejected at save time instead of being stored.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c724f854-c447-4a74-a127-2495dd1df1cc

📥 Commits

Reviewing files that changed from the base of the PR and between 1133b30 and 97b9c33.

📒 Files selected for processing (14)
  • internal/api/handlers/instances.go
  • internal/api/handlers/torrents_download_test.go
  • internal/linkdir/linkdir.go
  • internal/linkdir/linkdir_test.go
  • internal/models/crossseed_blocklist_test.go
  • internal/models/dirscan_run_status_test.go
  • internal/models/instance.go
  • internal/models/instance_test.go
  • internal/models/postgres_bool_args_test.go
  • internal/services/crossseed/hardlink_mode_test.go
  • internal/services/crossseed/service.go
  • internal/services/crossseed/service_search_test.go
  • internal/services/dirscan/cancel_scan_test.go
  • internal/services/dirscan/inject.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/linkdir/linkdir_test.go

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/api/handlers/instances.go (1)

609-624: ⚠️ Potential issue | 🟠 Major

Return 400 for invalid LinkDirName input.

The new store test in internal/models/instance_test.go shows Create/Update can now return invalid link directory name for bad user input, but both handlers still collapse store validation failures into 500. Malformed linkDirName payloads will therefore surface as internal server errors instead of bad requests.

Also applies to: 745-753

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/handlers/instances.go` around lines 609 - 624, When
h.instanceStore.Create returns an error, treat a validation error for the link
directory as a 400 not 500: check the returned error for the text "invalid link
directory name" (or errors.Is against the store's specific sentinel error if
available) and call RespondError(w, http.StatusBadRequest, ...) in that case;
otherwise keep the existing 500 handling. Apply the same change to the
corresponding Update call (h.instanceStore.Update) around the other handler
block (the code region at ~745-753) so malformed LinkDirName payloads produce
HTTP 400 responses.
🧹 Nitpick comments (2)
internal/linkdir/linkdir_test.go (1)

17-106: Prefer a table-driven suite for these linkdir cases.

This is already a compact input/output matrix, so folding it into table-driven tests would cut duplication and make new edge cases much easier to add.

As per coding guidelines, "Prefer table-driven test cases in Go tests".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/linkdir/linkdir_test.go` around lines 17 - 106, Collapse the
separate tests into table-driven subtests: create a slice of test cases
(structs) for each logical group that include fields for name, inputs and
expected output/error, then range over them calling t.Run(tc.name, func(t
*testing.T){ t.Parallel(); ... assertions ... }); replace the standalone
functions TestEffectiveInstanceDirName,
TestEffectiveInstanceDirName_RejectsTraversal, TestValidateInstanceDirName,
TestBuildDestDir_ByInstanceUsesOverride,
TestBuildDestDir_ByTrackerFallsBackToUnknown,
TestBuildDestDir_ByInstanceRejectsTraversal and TestFindMatchingBaseDir with
table-driven variants that exercise EffectiveInstanceDirName,
ValidateInstanceDirName, BuildDestDir and FindMatchingBaseDir respectively,
using require.NoError/require.ErrorContains/assert.Equal/assert.Contains as in
the originals so behavior and checks remain identical while reducing
duplication.
internal/models/instance_test.go (1)

451-455: Assert the row is unchanged after the rejected update.

This only verifies that Update returns an error. It will not catch a partial write if validation runs after some fields are persisted. Please reload the instance after the failure and assert that LinkDirName is still the previous value.

🧪 Suggested assertion
 	_, err = store.Update(ctx, instance.ID, "Valid Instance", "http://localhost:8080", "testuser", "", nil, nil, &InstanceUpdateParams{LinkDirName: &invalidLinkDirName})
 	require.ErrorContains(t, err, "invalid link directory name")
+
+	reloaded, err := store.Get(ctx, instance.ID)
+	require.NoError(t, err)
+	assert.Equal(t, "", reloaded.LinkDirName)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/models/instance_test.go` around lines 451 - 455, After verifying
Update returned an error, reload the instance from the store (e.g., call
store.Get(ctx, instance.ID) or the appropriate fetch method) and assert that
instance.LinkDirName still equals the original value (the value returned by the
initial store.Create); this ensures no partial write occurred when Update with
InstanceUpdateParams{LinkDirName: &invalidLinkDirName} failed. Use the same
context and instance.ID to fetch and compare the LinkDirName field.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/api/handlers/instances.go`:
- Around line 609-620: The handler currently calls instanceStore.Create (in
CreateInstance) and then persistReannounceSettings separately, allowing partial
commits if the second write fails; change this to perform both writes atomically
by using a transaction or an atomic store operation provided by the store (e.g.,
begin a transaction, call instanceStore.Create and persistReannounceSettings
inside it, then commit; rollback on any error) so no instance is visible without
its reannounce settings; apply the same pattern to the UpdateInstance path and
the other mentioned call sites (around persistReannounceSettings at 627-630,
735-758, 760-765) to ensure either both instance and reannounce settings persist
or neither do.

In `@internal/models/instance.go`:
- Around line 361-367: The validation currently treats any non-nil linkDirName
as an override and fails on empty/whitespace; change the logic in the block that
sets linkDirValue (and the similar block at 703-737) to treat "" or
all-whitespace as “no override”: trim the string pointer value, if the trimmed
result is empty then leave linkDirValue empty (do not call
linkdir.ValidateInstanceDirName), otherwise validate the trimmed value with
linkdir.ValidateInstanceDirName and assign the trimmed value to linkDirValue;
ensure you only call ValidateInstanceDirName when there is a non-empty trimmed
value.

---

Outside diff comments:
In `@internal/api/handlers/instances.go`:
- Around line 609-624: When h.instanceStore.Create returns an error, treat a
validation error for the link directory as a 400 not 500: check the returned
error for the text "invalid link directory name" (or errors.Is against the
store's specific sentinel error if available) and call RespondError(w,
http.StatusBadRequest, ...) in that case; otherwise keep the existing 500
handling. Apply the same change to the corresponding Update call
(h.instanceStore.Update) around the other handler block (the code region at
~745-753) so malformed LinkDirName payloads produce HTTP 400 responses.

---

Nitpick comments:
In `@internal/linkdir/linkdir_test.go`:
- Around line 17-106: Collapse the separate tests into table-driven subtests:
create a slice of test cases (structs) for each logical group that include
fields for name, inputs and expected output/error, then range over them calling
t.Run(tc.name, func(t *testing.T){ t.Parallel(); ... assertions ... }); replace
the standalone functions TestEffectiveInstanceDirName,
TestEffectiveInstanceDirName_RejectsTraversal, TestValidateInstanceDirName,
TestBuildDestDir_ByInstanceUsesOverride,
TestBuildDestDir_ByTrackerFallsBackToUnknown,
TestBuildDestDir_ByInstanceRejectsTraversal and TestFindMatchingBaseDir with
table-driven variants that exercise EffectiveInstanceDirName,
ValidateInstanceDirName, BuildDestDir and FindMatchingBaseDir respectively,
using require.NoError/require.ErrorContains/assert.Equal/assert.Contains as in
the originals so behavior and checks remain identical while reducing
duplication.

In `@internal/models/instance_test.go`:
- Around line 451-455: After verifying Update returned an error, reload the
instance from the store (e.g., call store.Get(ctx, instance.ID) or the
appropriate fetch method) and assert that instance.LinkDirName still equals the
original value (the value returned by the initial store.Create); this ensures no
partial write occurred when Update with InstanceUpdateParams{LinkDirName:
&invalidLinkDirName} failed. Use the same context and instance.ID to fetch and
compare the LinkDirName field.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 922e14f7-e184-40e9-8740-a428f9e1bd44

📥 Commits

Reviewing files that changed from the base of the PR and between 97b9c33 and 9b1409e.

📒 Files selected for processing (6)
  • internal/api/handlers/instances.go
  • internal/linkdir/linkdir.go
  • internal/linkdir/linkdir_test.go
  • internal/models/instance.go
  • internal/models/instance_test.go
  • internal/web/swagger/openapi.yaml

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
internal/models/instance.go (1)

748-754: Side effect on input parameter params.LinkDirName.

Line 753 mutates the caller's params.LinkDirName pointer by reassigning it to the normalized value. This works but leaks internal normalization state back to the caller, which could cause subtle bugs if the caller reuses params. Consider using a local variable instead.

♻️ Suggested fix
 	if params != nil {
+		var normalizedLinkDirName *string
 		if params.LinkDirName != nil {
 			linkDirValue, err := normalizeLinkDirName(params.LinkDirName)
 			if err != nil {
 				return nil, err
 			}
-			params.LinkDirName = &linkDirValue
+			normalizedLinkDirName = &linkDirValue
 		}
 
 		if params.TLSSkipVerify != nil {
 ...
-		if params.LinkDirName != nil {
+		if normalizedLinkDirName != nil {
 			query += ", link_dir_name = ?"
-			args = append(args, *params.LinkDirName)
+			args = append(args, *normalizedLinkDirName)
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/models/instance.go` around lines 748 - 754, The code mutates the
caller's params by overwriting params.LinkDirName with the normalized value;
instead, call normalizeLinkDirName(params.LinkDirName) into a local variable and
use that local normalized value for subsequent logic without assigning it back
to params.LinkDirName so the original input pointer is not mutated (refer to
normalizeLinkDirName and the params.LinkDirName usage in this block).
internal/api/handlers/instances.go (1)

801-808: normalizeOptionalString always returns a pointer, even for empty strings.

This function trims whitespace but returns &trimmed even when trimmed is empty. This means an empty/whitespace input will pass "" to the store rather than nil. The store's normalizeLinkDirName handles this correctly (treating "" as no override), so this works, but the semantic distinction between "user explicitly cleared the field" vs "user didn't provide the field" is lost.

If preserving nil for "not provided" is intended, consider:

♻️ Alternative that preserves nil for empty
 func normalizeOptionalString(value *string) *string {
 	if value == nil {
 		return nil
 	}
 
 	trimmed := strings.TrimSpace(*value)
+	if trimmed == "" {
+		return nil
+	}
 	return &trimmed
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/handlers/instances.go` around lines 801 - 808, The function
normalizeOptionalString currently trims input but always returns a non-nil
pointer, losing the distinction between "not provided" and "explicitly cleared";
update normalizeOptionalString to return nil when the trimmed result is empty
(i.e., if value==nil -> nil; else trim and if trimmed=="" -> nil; otherwise
return a pointer to the trimmed string), ensuring you allocate a stable string
variable for the pointer; this preserves the semantic difference (and keeps
normalizeLinkDirName's behavior unchanged).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/api/handlers/instances.go`:
- Around line 801-808: The function normalizeOptionalString currently trims
input but always returns a non-nil pointer, losing the distinction between "not
provided" and "explicitly cleared"; update normalizeOptionalString to return nil
when the trimmed result is empty (i.e., if value==nil -> nil; else trim and if
trimmed=="" -> nil; otherwise return a pointer to the trimmed string), ensuring
you allocate a stable string variable for the pointer; this preserves the
semantic difference (and keeps normalizeLinkDirName's behavior unchanged).

In `@internal/models/instance.go`:
- Around line 748-754: The code mutates the caller's params by overwriting
params.LinkDirName with the normalized value; instead, call
normalizeLinkDirName(params.LinkDirName) into a local variable and use that
local normalized value for subsequent logic without assigning it back to
params.LinkDirName so the original input pointer is not mutated (refer to
normalizeLinkDirName and the params.LinkDirName usage in this block).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4bf21dac-1c3f-4fbd-bb28-1839623b2411

📥 Commits

Reviewing files that changed from the base of the PR and between 9b1409e and 8113329.

📒 Files selected for processing (4)
  • internal/api/handlers/instances.go
  • internal/models/instance.go
  • internal/models/instance_reannounce.go
  • internal/models/instance_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/models/instance_test.go

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FR: Separate hardlink/reflink directories per instance

1 participant