aboutsummaryrefslogtreecommitdiffstats
path: root/cli/src/core/purpose.ts
blob: 728f27b5e204511e7d0cebd61f60ddfcbfa8f16e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
/**
 * Tab identity.
 *
 * A project tab is keyed by its zellij tab `purpose`, because that is the only
 * field the fork guarantees is (a) queryable via `list-tabs --json`, (b) not
 * overridable in-band by whatever a pane is displaying, and (c) restored
 * through the layout after a session is resurrected. Tab *names* are UI strings
 * a human renames; they are not identity.
 *
 * The purpose carries the project's path RELATIVE TO THE ROOT, because
 * `list-panes --json` exposes no `cwd` (verified against a live session), so
 * there is no other way to get from a tab back to a directory on disk. Slashes
 * survive the fork's sanitiser, so nested paths work.
 *
 * The fork's sanitiser: keep [A-Za-z0-9_.:/-], then truncate to 64 chars.
 * See sanitize_pane_purpose() in zellij-utils/src/input/layout.rs.
 */

import { createHash } from "node:crypto";

/** MAX_PANE_PURPOSE in the fork. */
export const PURPOSE_MAX = 64;
export const PROJECT_PREFIX = "project:";

/** Everything the fork's sanitiser strips. */
const DISALLOWED = /[^A-Za-z0-9_.:/-]/g;

/** Reproduces sanitize_pane_purpose(): filter, then truncate. */
export function sanitizePurpose(raw: string): string {
  return raw.replace(DISALLOWED, "").slice(0, PURPOSE_MAX);
}

function hash8(value: string): string {
  return createHash("sha256").update(value).digest("hex").slice(0, 8);
}

/**
 * Marks a purpose whose path was lossy and must never be reconstructed.
 *
 * A leading dot is safe as a sentinel because discovery skips dot-entries, so
 * no real relative path we encode can begin with one.
 *
 * This has to be *structural*. The first implementation just re-encoded the
 * candidate and compared, which silently failed: `project:<47 a's>.<hash8>` is
 * itself 64 chars of legal characters, so it re-encodes to itself, the check
 * passed, and a bogus path was "reconstructed" — which would have closed a
 * live tab. Caught by `refuses to reconstruct a truncated/hashed purpose`.
 */
const LOSSY_MARK = ".";

/**
 * The purpose for a project at `relPath` (relative to its root).
 *
 * Normally this is simply `project:<relPath>`, which round-trips back to a
 * path. When the path would not survive sanitising or does not fit, we fall
 * back to a marked, truncated form plus a hash: still *unique*, deliberately
 * not *reconstructable*, so `projectRelPath()` refuses it and we never
 * auto-close a tab we cannot verify.
 */
export function projectPurpose(relPath: string): string {
  const cleaned = sanitizePurpose(relPath);
  const exact =
    cleaned === relPath &&
    cleaned.length > 0 &&
    !cleaned.startsWith(LOSSY_MARK) &&
    PROJECT_PREFIX.length + cleaned.length <= PURPOSE_MAX;
  if (exact) return PROJECT_PREFIX + cleaned;

  // sentinel(1) + head + "."(1) + hash8(8)
  const budget = PURPOSE_MAX - PROJECT_PREFIX.length - 10;
  const head = cleaned.slice(0, budget).replace(/[./-]+$/, "") || "project";
  return `${PROJECT_PREFIX}${LOSSY_MARK}${head}.${hash8(relPath)}`;
}

export function isProjectPurpose(purpose: string | undefined | null): boolean {
  return typeof purpose === "string" && purpose.startsWith(PROJECT_PREFIX);
}

/**
 * The relative path a purpose encodes, or null when it cannot be reconstructed
 * exactly.
 *
 * Null is not a failure, it is a refusal. A lossy purpose is marked at encode
 * time and rejected here, so callers cannot mistake `project:.head.a1b2c3d4`
 * for a directory literally named `head.a1b2c3d4` and close a tab over it. The
 * re-encode comparison stays as a second line of defence.
 */
export function projectRelPath(purpose: string): string | null {
  if (!isProjectPurpose(purpose)) return null;
  const candidate = purpose.slice(PROJECT_PREFIX.length);
  if (candidate.length === 0) return null;
  if (candidate.startsWith(LOSSY_MARK)) return null;
  return projectPurpose(candidate) === purpose ? candidate : null;
}