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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,12 @@ Use `--env=base` to bypass that default and read only `.hack/hack.env.default.ya
command that accepts `--env`; an overlay file named after one of those spellings is never
selected by name. Any other value must already be a canonical overlay name: lowercase
letters and digits joined by single hyphens (`^[a-z0-9]+(-[a-z0-9]+)*$`, for example
`qa` or `qa-2`); surrounding whitespace is ignored. Hack never rewrites a requested name,
so `QA`, `q_a` or `X=1` is refused with a fixed `Invalid --env value.` message that does
not repeat the argument, before any project file is read or any child process starts.
`qa` or `qa-2`), at most 128 characters long; surrounding whitespace is ignored. Hack
never rewrites a requested name, so `QA`, `q_a`, `X=1` or a name longer than 128
characters is refused with a fixed `Invalid --env value.` message (`E_USAGE` under
`--json`) that does not repeat the argument, before any project file is read or any
child process starts. The limit keeps every file name Hack derives from the overlay
name, such as the layer's lock, within the usual 255-byte file name limit.
See the [command and delivery matrix](#command-and-delivery-matrix) for what each command
does with the selection.

Expand Down Expand Up @@ -637,7 +640,28 @@ message in the envelope:
you cannot write (for example mode `0555`) both give this code.
- `E_NO_SPACE`: the disk or the disk quota is full.
- `E_PATH_CONFLICT`: a path is the wrong kind of entry, such as a file where Hack needs
a directory, or a directory where Hack needs a file.
a directory, a directory where Hack needs a file (including a `.hack/.gitignore` that
is a directory or a symlink), or a `.hack/.internal` that `hack env hydrate` finds to
be a symlink (dangling or not) or a file. A file name or path that is too long for
the filesystem is reported with this code too; use a shorter `--env` overlay name or
move the project to a shorter path.
- `E_LOCKED`: the project env key lock (`<git common dir>/.hack.secret.key.lock`, taken
by the first `--secret` value) stayed held for the whole 10-second wait. Nothing was
written. Another hack command may be creating the key; retry when it finishes. If no
other hack command is running, the lock is stale (for example its process ID now
belongs to an unrelated process, which Hack never treats as abandoned): remove the
lock file the message names, then retry.

The first `--secret` value also works on a filesystem without hard links, such as ExFAT
or FAT on a USB drive. Hack normally publishes the key file and its locks with a hard
link, so they appear complete or not at all. Where that is not possible, it creates an
empty placeholder exclusively and renames the complete file over it, which keeps the
same rules: an existing file or symlink is never replaced or followed, and a reader
never sees a partial file. If Hack is killed between those two steps, an empty
`.hack.secret.key` or lock file is left behind. Hack refuses it rather than use or
replace it; remove it and run the command again. ExFAT and FAT do not store Unix
permissions, so on such a drive the key file is not limited to your user the way mode
`0600` limits it elsewhere.

If you omit `key`, `value`, or `--service`, `hack env add` prompts for them interactively.
Pass `--no-interactive` (or set `HACK_NO_INTERACTIVE=1`) for scripted/agent usage: the
Expand Down Expand Up @@ -892,7 +916,7 @@ Write-path guarantees in a linked worktree (first secret added from any checkout
- otherwise an existing shared key (git common dir) is reused
- otherwise the primary checkout's key is adopted by copying it to the shared location, so sibling checkouts converge on one key
- only then is a new key generated, and it is written to the shared location — never silently to the checkout
- concurrent first use from several checkouts converges on one key: when no key exists, creation runs under a cooperative lock at `<git common dir>/.hack.secret.key.lock`, and a key file is published only when complete, never empty or partial (Hack v4 writers do not take the lock)
- concurrent first use from several checkouts converges on one key: when no key exists, creation runs under a cooperative lock at `<git common dir>/.hack.secret.key.lock`, and a key file is published only when complete, never empty or partial (Hack v4 writers do not take the lock; on a filesystem without hard links, such as ExFAT, an empty placeholder is visible for a moment before the complete key replaces it)
- if the shared location cannot be resolved (git unavailable) or written, Hack falls back to a checkout-local key and prints a loud divergence warning
- `hack doctor` flags divergent `.hack.secret.key` contents across checkouts with the exact paths

Expand Down
3 changes: 2 additions & 1 deletion plugins/hack/skills/hack-diagnose/references/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ stay within the request.
| `E_PERMISSION` | A filesystem or runtime permission was denied, or a file Hack must write is on a read-only filesystem. | `hack doctor --json` |
| `E_NO_SPACE` | A file Hack must write could not be written because the disk is full or the disk quota is exceeded. | `df -h <path from the message>` |
| `E_PATH_CONFLICT` | A path Hack must read or write is the wrong kind of entry: a file where a directory is needed, or a directory or special file where a regular file is needed. | `ls -ld <path from the message>` |
| `E_LOCKED` | A lock Hack needs stayed held for the whole bounded wait, by another Hack command or by a stale lock Hack cannot prove abandoned; nothing guarded by it was written. | `ls -l <lock path from the message>` |
| `E_INTERACTIVE_REQUIRED` | The command needs an answer at a prompt but is running without one. | `hack <command> --help` |
| `E_USAGE` | Arguments or flags were invalid for the command. | `hack <command> --help` |
| `E_UNEXPECTED` | An unclassified failure; the message is the only detail. | `hack doctor --json` |

---

Content revision: `4c1343788d6a`. Generated by Hack; edit the canonical source and regenerate the plugin.
Content revision: `dc08a206e03d`. Generated by Hack; edit the canonical source and regenerate the plugin.
5 changes: 5 additions & 0 deletions src/agents/error-code-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ const ERROR_CODE_GUIDES: Readonly<Record<HackErrorCode, ErrorCodeGuide>> = {
"A path Hack must read or write is the wrong kind of entry: a file where a directory is needed, or a directory or special file where a regular file is needed.",
readFirst: "`ls -ld <path from the message>`",
},
E_LOCKED: {
meaning:
"A lock Hack needs stayed held for the whole bounded wait, by another Hack command or by a stale lock Hack cannot prove abandoned; nothing guarded by it was written.",
readFirst: "`ls -l <lock path from the message>`",
},
E_INTERACTIVE_REQUIRED: {
meaning:
"The command needs an answer at a prompt but is running without one.",
Expand Down
11 changes: 7 additions & 4 deletions src/cli/env-selection.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { parseEnvConfigSelection } from "../lib/project.ts";
import {
ENV_CONFIG_NAME_MAX_LENGTH,
parseEnvConfigSelection,
} from "../lib/project.ts";
import { CliUsageError } from "./command.ts";

/**
* Fixed refusal for an unusable `--env` value. It names the accepted grammar
* and never the argument, which may be a mistyped `KEY=value` or a pasted
* value.
*/
export const INVALID_ENV_SELECTION_MESSAGE =
"Invalid --env value. Overlay names must match ^[a-z0-9]+(-[a-z0-9]+)*$; use base, default or none for base layers only.";
export const INVALID_ENV_SELECTION_MESSAGE = `Invalid --env value. Overlay names must match ^[a-z0-9]+(-[a-z0-9]+)*$ and be at most ${ENV_CONFIG_NAME_MAX_LENGTH} characters; use base, default or none for base layers only.`;

/**
* Resolve a CLI `--env` value through the one shared selection grammar.
Expand All @@ -16,7 +18,8 @@ export const INVALID_ENV_SELECTION_MESSAGE =
* - `null`: base layers only. `base`, `default` and `none` (any case) and an
* empty value bypass the configured default overlay.
* - `string`: the overlay name, which must already be canonical
* (`ENV_CONFIG_NAME_PATTERN`); surrounding whitespace is ignored.
* (`ENV_CONFIG_NAME_PATTERN`) and at most `ENV_CONFIG_NAME_MAX_LENGTH`
* characters; surrounding whitespace is ignored.
*
* Anything else, including any value containing `=`, is refused with a fixed,
* value-free usage error. A requested name is never slugged: slugging
Expand Down
1 change: 1 addition & 0 deletions src/lib/cli-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const HACK_ERROR_CODES = [
"E_PERMISSION",
"E_NO_SPACE",
"E_PATH_CONFLICT",
"E_LOCKED",
"E_INTERACTIVE_REQUIRED",
"E_USAGE",
"E_UNEXPECTED",
Expand Down
149 changes: 149 additions & 0 deletions src/lib/exclusive-publish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { constants, type Stats } from "node:fs";
import { link, lstat, open, rename, unlink } from "node:fs/promises";
import { isRecord } from "./guards.ts";

/**
* Exclusive publication of a complete file at a path that must not already
* exist, for filesystems with and without hard links.
*
* Ownership: the caller owns `source` (a fully written, fsynced sibling it
* created exclusively) and decides what an occupied `target` means. These
* helpers never follow a symlink at `target` and never replace an entry they
* did not create.
*/

/**
* `link(2)` failures that mean the filesystem cannot make hard links: ExFAT
* and FAT report ENOTSUP on macOS and EPERM on Linux. A real permission
* problem also reports EPERM; the fallback then fails with its own
* permission error, so nothing is hidden.
*/
const HARD_LINK_UNSUPPORTED_CODES: ReadonlySet<string> = new Set([
"ENOTSUP",
"EOPNOTSUPP",
"EPERM",
]);

/** Whether a `link(2)` error means the filesystem has no hard links. */
export function isHardLinkUnsupportedError(error: unknown): boolean {
return (
isRecord(error) &&
typeof error.code === "string" &&
HARD_LINK_UNSUPPORTED_CODES.has(error.code)
);
}

/**
* Publishes `source` at `target` only when no entry exists at `target`, and
* reports whether this call published it. `source` must be on the same
* filesystem (a sibling).
*
* Primary path: `link(source, target)`, which fails with EEXIST instead of
* replacing anything; `source` keeps its name. When the filesystem has no
* hard links ({@link isHardLinkUnsupportedError}), it falls back to
* {@link publishByExclusivePlaceholder}, after which `source` no longer
* exists. Callers that remove `source` afterwards must tolerate ENOENT.
*
* EEXIST resolves to false; every other error propagates unchanged.
*/
export async function publishFileExclusive(opts: {
readonly source: string;
readonly target: string;
}): Promise<boolean> {
try {
await link(opts.source, opts.target);
return true;
} catch (error: unknown) {
if (hasCode(error, "EEXIST")) {
return false;
}
if (!isHardLinkUnsupportedError(error)) {
throw error;
}
}
return await publishByExclusivePlaceholder(opts);
}

/**
* Publication without hard links: reserve `target` with an empty
* O_CREAT|O_EXCL|O_NOFOLLOW file (mode 0600), then rename the complete
* `source` over that reservation.
*
* Guarantees kept from the hard-link path:
* - no overwrite: the exclusive create fails with EEXIST on any existing
* entry, including a dangling symlink, and the rename replaces only the
* empty regular file this call created. It is checked again (still an
* empty regular file with the same identity) immediately before the
* rename; if anything else is there the call reports the path as occupied
* and leaves it alone.
* - no follow: neither O_EXCL|O_NOFOLLOW nor rename(2) follows a symlink at
* the final component.
* - readers see either nothing, the empty reservation, or the complete file,
* never a partial one.
*
* Difference: a process killed between the reservation and the rename
* leaves an empty file at `target`. Callers already treat an empty file as
* invalid, never as theirs, so it is refused rather than adopted or
* reclaimed; it must be removed by hand. A failed rename removes the
* reservation while it is still the empty file this call created.
*/
export async function publishByExclusivePlaceholder(opts: {
readonly source: string;
readonly target: string;
}): Promise<boolean> {
let reserved: Stats;
try {
const placeholder = await open(
opts.target,
constants.O_CREAT |
constants.O_EXCL |
constants.O_NOFOLLOW |
constants.O_WRONLY,
0o600
);
try {
reserved = await placeholder.stat();
} finally {
await placeholder.close();
}
} catch (error: unknown) {
if (hasCode(error, "EEXIST")) {
return false;
}
throw error;
}
if (!(await isOwnReservation({ path: opts.target, reserved }))) {
return false;
}
try {
await rename(opts.source, opts.target);
} catch (error: unknown) {
if (await isOwnReservation({ path: opts.target, reserved })) {
await unlink(opts.target).catch(() => undefined);
}
throw error;
}
return true;
}

/**
* Whether `path` still names the empty regular file this call reserved.
* Size is checked as well as identity: some filesystems without hard links
* (ExFAT) give every empty file the same inode number.
*/
async function isOwnReservation(opts: {
readonly path: string;
readonly reserved: Stats;
}): Promise<boolean> {
const current = await lstat(opts.path).catch(() => null);
return (
current?.isFile() === true &&
current.size === 0 &&
current.dev === opts.reserved.dev &&
current.ino === opts.reserved.ino
);
}

function hasCode(error: unknown, code: string): boolean {
return isRecord(error) && error.code === code;
}
Loading
Loading