@feralfile/cli 2.8.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package.json +1 -1
- package/dist/src/commands/config.js +12 -5
- package/dist/src/commands/enrich.js +135 -32
- package/dist/src/commands/publish.js +58 -1
- package/dist/src/commands/setup.js +42 -15
- package/dist/src/commands/sign.js +82 -7
- package/dist/src/commands/status.js +28 -2
- package/dist/src/commands/unpublish.js +52 -9
- package/dist/src/utilities/feed-mutation.js +119 -23
- package/dist/src/utilities/playlist-publisher.js +15 -26
- package/dist/src/utilities/playlist-signer.js +367 -22
- package/dist/src/utilities/playlist-unpublisher.js +11 -30
- package/dist/src/utilities/same-file.js +52 -0
- package/dist/src/utilities/signing-key-source.js +230 -0
- package/docs/CONFIGURATION.md +59 -3
- package/docs/EXAMPLES.md +209 -9
- package/docs/PROJECT_SPEC.md +45 -6
- package/docs/README.md +48 -7
- package/package.json +1 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Where an explicit signing key came from: `--key` on the command line, or `--key-file` on disk.
|
|
4
|
+
*
|
|
5
|
+
* `--key` puts a private key in two places nobody chose to put it: the shell history file, and the
|
|
6
|
+
* process list, where any local user can read it for as long as the command runs. Neither is something
|
|
7
|
+
* the operator can take back afterwards, and an Ed25519 signing key that has been disclosed is not
|
|
8
|
+
* repairable — the identity it asserts is the identity, and a playlist's owner set is immutable. So
|
|
9
|
+
* every command that accepts `--key` also accepts `--key-file <path>`, which reads the same material
|
|
10
|
+
* from a file the operator controls.
|
|
11
|
+
*
|
|
12
|
+
* The two flags are mutually exclusive rather than ordered. A precedence rule would have to pick a
|
|
13
|
+
* winner when they disagree, and the loser is silently ignored — which is exactly the class of failure
|
|
14
|
+
* the empty-`--key` fix removed: a key the operator believes was used and was not.
|
|
15
|
+
*
|
|
16
|
+
* Reading the file is deliberately *not* gated on its permissions. A mode check would refuse work on
|
|
17
|
+
* key files that are perfectly appropriate — a file inside a per-user directory, a path on a tmpfs
|
|
18
|
+
* mounted for one user, a Windows ACL the mode bits cannot describe — and it would still not prove the
|
|
19
|
+
* file is private, because the directory above it decides who can reach the name at all. The
|
|
20
|
+
* documentation says to keep the file private; the CLI does not pretend to be able to check it.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.explicitSigningKeyFlag = explicitSigningKeyFlag;
|
|
24
|
+
exports.readSigningKeyFile = readSigningKeyFile;
|
|
25
|
+
exports.resolveExplicitSigningKey = resolveExplicitSigningKey;
|
|
26
|
+
const node_fs_1 = require("node:fs");
|
|
27
|
+
/**
|
|
28
|
+
* Which key flag the command was given, without reading anything.
|
|
29
|
+
*
|
|
30
|
+
* Split out from the resolution below so a command that is going to *refuse* an explicit key — a plain
|
|
31
|
+
* `publish` signs nothing at request time — can name the flag the operator actually typed without
|
|
32
|
+
* opening their key file to do it.
|
|
33
|
+
*
|
|
34
|
+
* Presence, never truthiness: `--key ""` and `--key-file ""` are both what an unset shell variable
|
|
35
|
+
* expands to, and both are overrides that failed rather than overrides that are absent.
|
|
36
|
+
*
|
|
37
|
+
* @param options - The command's parsed options
|
|
38
|
+
* @returns The flag used, or `undefined` when neither was given
|
|
39
|
+
* @throws Error when both flags are present
|
|
40
|
+
*/
|
|
41
|
+
function explicitSigningKeyFlag(options) {
|
|
42
|
+
const hasKey = options.key !== undefined;
|
|
43
|
+
const hasKeyFile = options.keyFile !== undefined;
|
|
44
|
+
if (hasKey && hasKeyFile) {
|
|
45
|
+
throw new Error('--key and --key-file both name a signing key, and nothing here can tell which one you meant. ' +
|
|
46
|
+
'Pass exactly one: --key for material on the command line, --key-file for a path to read it from.');
|
|
47
|
+
}
|
|
48
|
+
if (hasKeyFile) {
|
|
49
|
+
return '--key-file';
|
|
50
|
+
}
|
|
51
|
+
return hasKey ? '--key' : undefined;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Read key material out of a key file.
|
|
55
|
+
*
|
|
56
|
+
* The contents are trimmed, so a file written by `printf`, by an editor that appends a newline, or by a
|
|
57
|
+
* password manager's export all behave the same. What comes back then goes through the *same*
|
|
58
|
+
* normalization `--key` does — base64 PKCS#8 DER, a 32-byte seed as hex or base64, or PEM — because a
|
|
59
|
+
* key file is a delivery mechanism, not a second key format.
|
|
60
|
+
*
|
|
61
|
+
* An empty or whitespace-only file is rejected here rather than passed on. Every other outcome would be
|
|
62
|
+
* a silent fallback to the configured key: `sign` and the feed mutations would refuse an empty `--key`
|
|
63
|
+
* with a message about shell expansion that is wrong for a file, and `status` would quietly report the
|
|
64
|
+
* configured identity as though it were the file's. That fallback is the bug the empty-`--key` fix
|
|
65
|
+
* removed, and it is worse from a file, because a file that reads as empty (truncated, still being
|
|
66
|
+
* written, the wrong path in a directory of similar names) looks nothing like a typo at the prompt.
|
|
67
|
+
*
|
|
68
|
+
* Every argument is opened. Deciding in advance that something "looks like a key" and refusing to try
|
|
69
|
+
* cost real paths — `/run/BEGIN/secrets/owner.key`, a content-addressed 64-hex filename — and bought
|
|
70
|
+
* nothing, because opening a wrong path is free. What is not free is *printing* one: a private key
|
|
71
|
+
* that reaches a terminal is in scrollback and in whatever ships those logs onward, and it cannot be
|
|
72
|
+
* rotated out of a document that already names it. So the guarantee lives entirely in what may be said
|
|
73
|
+
* about a failure; see `safePathLabel`. Existence is also the only thing separating a key typed into
|
|
74
|
+
* the path from a file genuinely named after a hex digest, and that cannot be known without looking.
|
|
75
|
+
*
|
|
76
|
+
* @param path - Value of `--key-file` exactly as the command received it
|
|
77
|
+
* @returns Trimmed key material
|
|
78
|
+
* @throws Error when the path is empty, cannot be read, or holds no key material
|
|
79
|
+
*/
|
|
80
|
+
function readSigningKeyFile(path) {
|
|
81
|
+
if (path.trim().length === 0) {
|
|
82
|
+
throw new Error('The --key-file path is empty. This usually means a shell variable did not expand (for example ' +
|
|
83
|
+
'--key-file "$KEY_PATH" with KEY_PATH unset). Refusing rather than falling back to the ' +
|
|
84
|
+
'configured key, which would sign under an identity you did not choose.');
|
|
85
|
+
}
|
|
86
|
+
// The path is used exactly as given, NOT trimmed. Trimming was only ever a convenience, and it is
|
|
87
|
+
// the wrong one here: it silently opens `owner.key` for `--key-file "owner.key "`, so a file that
|
|
88
|
+
// is not the one named decides what signs. The trim above answers a different question — whether
|
|
89
|
+
// anything was passed at all.
|
|
90
|
+
let contents;
|
|
91
|
+
try {
|
|
92
|
+
contents = (0, node_fs_1.readFileSync)(path, 'utf-8');
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
throw new Error(describeKeyFileReadFailure(path, error));
|
|
96
|
+
}
|
|
97
|
+
const material = contents.trim();
|
|
98
|
+
if (material.length === 0) {
|
|
99
|
+
throw new Error(`The key file at ${safePathLabel(path)} holds no key material. Refusing rather than falling ` +
|
|
100
|
+
'back to the configured key: a signature made under an identity you did not choose is not ' +
|
|
101
|
+
'something a publish or a delete can be taken back from.');
|
|
102
|
+
}
|
|
103
|
+
return material;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Whether a value is, on its own, a complete Ed25519 key encoding.
|
|
107
|
+
*
|
|
108
|
+
* Complete is the whole point. An earlier version treated any `BEGIN` substring and any key-shaped
|
|
109
|
+
* last segment as reason enough to refuse before opening, which made `/run/BEGIN/secrets/owner.key`
|
|
110
|
+
* and a content-addressed 64-hex filename unusable — real paths, refused for looking like something
|
|
111
|
+
* they were not, with no way to say otherwise. The shapes below are the forms a whole key takes and
|
|
112
|
+
* nothing else: a PEM block carries both its header AND the line breaks a real block has, a hex seed
|
|
113
|
+
* is 64 characters of nothing but hex, and base64 is judged by what it decodes to.
|
|
114
|
+
*/
|
|
115
|
+
function isWholeKeyEncoding(value) {
|
|
116
|
+
if (value.includes('BEGIN') && /[\r\n]/.test(value)) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
if (/^(0x)?[0-9a-fA-F]{64,}$/.test(value)) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
// Decided by DECODED LENGTH rather than by charset alone: `/` and `+` are legal base64 and most
|
|
123
|
+
// real PKCS#8 keys contain a `/`, so a charset test that read that as a directory separator would
|
|
124
|
+
// let the common case through. 32 bytes is a raw Ed25519 seed; 48 is PKCS#8 for one.
|
|
125
|
+
if (/^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
|
|
126
|
+
const decodedLength = Buffer.from(value, 'base64').length;
|
|
127
|
+
if (decodedLength === 32 || decodedLength === 48) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
/** Whether the argument is a bare key: a complete encoding with no path structure around it. */
|
|
134
|
+
function isBareKeyMaterial(value) {
|
|
135
|
+
return !/[/\\]/.test(value) && isWholeKeyEncoding(value);
|
|
136
|
+
}
|
|
137
|
+
/** Whether any path segment is a complete key encoding — a key with a directory prefix on it. */
|
|
138
|
+
function anySegmentIsKeyEncoding(value) {
|
|
139
|
+
return value.split(/[/\\]/).some((segment) => isWholeKeyEncoding(segment));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* How to refer to a `--key-file` argument in a message, without ever printing key material.
|
|
143
|
+
*
|
|
144
|
+
* This is the guarantee, and it is deliberately separate from the decision to open. Opening a wrong
|
|
145
|
+
* path costs nothing; printing one can disclose a private key that cannot be rotated out of a document
|
|
146
|
+
* that already names it. So everything is opened, and this decides what may be said afterwards.
|
|
147
|
+
*
|
|
148
|
+
* Length carries most of it: no complete key encoding is under 40 characters — a 32-byte seed is 64
|
|
149
|
+
* hex or 44 base64 characters — so a short argument cannot be a whole key, and a partial one is not a
|
|
150
|
+
* key. The segment test then covers a key wearing a directory prefix, which length alone would pass
|
|
151
|
+
* only for something already too long to print.
|
|
152
|
+
*
|
|
153
|
+
* @param path - Value of `--key-file` exactly as the command received it
|
|
154
|
+
* @returns The path itself when it is provably safe to print, or a description of it
|
|
155
|
+
*/
|
|
156
|
+
function safePathLabel(path) {
|
|
157
|
+
const value = path.trim();
|
|
158
|
+
if (value.length < 40 && !anySegmentIsKeyEncoding(value)) {
|
|
159
|
+
return path;
|
|
160
|
+
}
|
|
161
|
+
return 'the path given (not repeated here, in case it is key material)';
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Turn a read failure into a sentence that says what to do about it.
|
|
165
|
+
*
|
|
166
|
+
* The raw errno text ("EISDIR: illegal operation on a directory, read") reads like a bug in the CLI
|
|
167
|
+
* rather than a wrong path, and it is the same three mistakes every time: the file is not there, it is
|
|
168
|
+
* not readable, or the path names a directory.
|
|
169
|
+
*
|
|
170
|
+
* The underlying `Error.message` is never interpolated — only its `code`. Node builds that message by
|
|
171
|
+
* appending the path it was given, so passing it through would echo the argument a second time, in a
|
|
172
|
+
* form no caller here controls.
|
|
173
|
+
*/
|
|
174
|
+
function describeKeyFileReadFailure(path, error) {
|
|
175
|
+
const code = error?.code;
|
|
176
|
+
const value = path.trim();
|
|
177
|
+
// A bare, complete key with nothing of a path about it, and no file of that name. `--key-file
|
|
178
|
+
// "$SIGNING_KEY"` is one keystroke from `--key "$SIGNING_KEY"`, and it is the natural typo for
|
|
179
|
+
// someone moving off `--key`, so say what the mistake was. Existence is what separates this from a
|
|
180
|
+
// file someone genuinely named after a hex digest — which is why the open is attempted first rather
|
|
181
|
+
// than guessed at.
|
|
182
|
+
if (isBareKeyMaterial(value)) {
|
|
183
|
+
return ('The --key-file argument looks like a key rather than a path, and no file of that name exists. ' +
|
|
184
|
+
'It is not repeated here. Point --key-file at the file that holds the key, or pass the key ' +
|
|
185
|
+
'itself with --key.');
|
|
186
|
+
}
|
|
187
|
+
const label = safePathLabel(path);
|
|
188
|
+
// When the argument cannot be printed, the errno is the whole message. Naming which failure it was
|
|
189
|
+
// is still worth saying — "there is nothing there" and "you may not read it" send an operator to
|
|
190
|
+
// different places — but nothing about the value itself may appear.
|
|
191
|
+
if (label !== path) {
|
|
192
|
+
return (`The --key-file argument could not be read (${code ?? 'read failed'}) and is not repeated ` +
|
|
193
|
+
'here: it may be key material rather than a path. Check the path you passed.');
|
|
194
|
+
}
|
|
195
|
+
if (code === 'ENOENT') {
|
|
196
|
+
return `No key file at ${label}.`;
|
|
197
|
+
}
|
|
198
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
199
|
+
return `Cannot read the key file at ${label}: permission denied (${code}).`;
|
|
200
|
+
}
|
|
201
|
+
if (code === 'EISDIR') {
|
|
202
|
+
return `${label} is a directory, not a key file.`;
|
|
203
|
+
}
|
|
204
|
+
return `Cannot read the key file at ${label} (${code ?? 'read failed'}).`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Resolve the explicit signing key a command was given, from either flag.
|
|
208
|
+
*
|
|
209
|
+
* Commands call this before anything else — before a feed is chosen, before a playlist is looked up,
|
|
210
|
+
* before a destructive action is confirmed — so an unreadable file fails while the only thing it can
|
|
211
|
+
* cost is the run itself.
|
|
212
|
+
*
|
|
213
|
+
* `--key` material is returned exactly as typed, including when it is empty: the commands already
|
|
214
|
+
* refuse an empty `--key` with a message about shell expansion, and that message is the right one for
|
|
215
|
+
* a value that came from the command line.
|
|
216
|
+
*
|
|
217
|
+
* @param options - The command's parsed options
|
|
218
|
+
* @returns The explicit key and its flag, or `undefined` when neither flag was given
|
|
219
|
+
* @throws Error when both flags are present, or when a key file cannot be read or is empty
|
|
220
|
+
*/
|
|
221
|
+
function resolveExplicitSigningKey(options) {
|
|
222
|
+
const flag = explicitSigningKeyFlag(options);
|
|
223
|
+
if (flag === undefined) {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
if (flag === '--key-file') {
|
|
227
|
+
return { material: readSigningKeyFile(options.keyFile), flag };
|
|
228
|
+
}
|
|
229
|
+
return { material: options.key, flag };
|
|
230
|
+
}
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -39,7 +39,7 @@ Optional settings used where headless/browser‑like behavior is needed.
|
|
|
39
39
|
|
|
40
40
|
Used for signing DP‑1 playlists.
|
|
41
41
|
|
|
42
|
-
- `playlist.privateKey` (string, Ed25519 private key in hex or base64): Used by the `sign` command to create DP-1 v1.1.0 multi-signatures. The `verify` command may derive the matching public key from this value (or `PLAYLIST_PRIVATE_KEY`) when you omit `--public-key`; **dp1-js applies that derived key only when verifying legacy flat `signature` strings**, not when checking `signatures[]` envelopes. If that derivation fails, `verify` prints a warning on stderr and continues without derived key material. The derived public key is emitted as PEM so Node can decode it without ambiguity. Hex may include or omit the `0x` prefix. You can also set this via `PLAYLIST_PRIVATE_KEY` in `.env`. `play` verifies playlists before delivery and only auto-signs the synthesized media URL fallback when signing is configured. `play` and `publish` verify before delivery or upload and reject unsigned or broken playlists.
|
|
42
|
+
- `playlist.privateKey` (string, Ed25519 private key in hex or base64): Used by the `sign` command to create DP-1 v1.1.0 multi-signatures, and as the default signing key for the owner-bound feed mutations (`publish --replace`, `unpublish`), which sign an authorization intent at request time. Those commands and `sign` all take `-k, --key` or `--key-file <path>` to override it for a single run — see [Where the signing key can come from](#where-the-signing-key-can-come-from). The `verify` command may derive the matching public key from this value (or `PLAYLIST_PRIVATE_KEY`) when you omit `--public-key`; **dp1-js applies that derived key only when verifying legacy flat `signature` strings**, not when checking `signatures[]` envelopes. If that derivation fails, `verify` prints a warning on stderr and continues without derived key material. The derived public key is emitted as PEM so Node can decode it without ambiguity. Hex may include or omit the `0x` prefix. You can also set this via `PLAYLIST_PRIVATE_KEY` in `.env`. `play` verifies playlists before delivery and only auto-signs the synthesized media URL fallback when signing is configured. `play` and `publish` verify before delivery or upload and reject unsigned or broken playlists.
|
|
43
43
|
|
|
44
44
|
**Signing and key encoding:** Signing paths (`sign`, deterministic `build` when configured, and `-k/--key` overrides) accept the private key in any of these encodings:
|
|
45
45
|
|
|
@@ -61,10 +61,64 @@ Used for signing DP‑1 playlists.
|
|
|
61
61
|
|
|
62
62
|
To read the `did:key` your signatures will carry, run `ff-cli status`. It reports the identity from
|
|
63
63
|
`PLAYLIST_PRIVATE_KEY` even when no config file exists yet, and `ff-cli status --key <privateKey>`
|
|
64
|
-
reports it for an explicit key — the one `sign
|
|
64
|
+
(or `--key-file <path>`) reports it for an explicit key — the one `sign` would use with the same flag.
|
|
65
65
|
|
|
66
66
|
- `playlist.role` (string): DP-1 signing role used by `ff-cli sign`. The shipped `config.json.example` sets `curator`, because a document signed as `agent` is ownerless to a role-aware feed and `ff-cli publish` refuses it. The code default when the key is omitted entirely is still `agent`. You can also set this via `PLAYLIST_ROLE` in `.env`. Guided `ff-cli setup`, `config validate`, and `sign --role` only accept the usual DP-1 signing roles (`agent`, `feed`, `curator`, `institution`, `licensor`). It does **not** apply to `find` and `build`, which always sign as `curator` — see [Signing role and ownership](#signing-role-and-ownership).
|
|
67
67
|
|
|
68
|
+
### Where the signing key can come from
|
|
69
|
+
|
|
70
|
+
Three sources, in this order of precedence:
|
|
71
|
+
|
|
72
|
+
1. **`-k, --key <privateKey>` or `--key-file <path>`** on the command line — `sign`, `status`,
|
|
73
|
+
`unpublish`, and `publish --replace` all take both.
|
|
74
|
+
2. **`playlist.privateKey`** in `config.json`.
|
|
75
|
+
3. **`PLAYLIST_PRIVATE_KEY`** in the environment or in `.env`.
|
|
76
|
+
|
|
77
|
+
The file beats the environment, matching the repo-wide `config.json` > `.env` > defaults rule — with
|
|
78
|
+
one exception: a `playlist.privateKey` that is empty or still holds a `YOUR_`/`your_` sample
|
|
79
|
+
placeholder is treated as **unset**, so `PLAYLIST_PRIVATE_KEY` is used instead. Without that, an
|
|
80
|
+
untouched `config init` would beat a perfectly good environment key and every signing path would fail
|
|
81
|
+
on a key you never chose.
|
|
82
|
+
|
|
83
|
+
An explicit key wins for that one run only; nothing is written back. All three accept the same
|
|
84
|
+
encodings listed above, and the CLI normalizes whichever you supply before signing, so a key file is a
|
|
85
|
+
delivery mechanism rather than a fourth key format.
|
|
86
|
+
|
|
87
|
+
**Prefer `--key-file` to `--key`.** `--key` writes your private key into your shell history file and
|
|
88
|
+
exposes it in the process list, where any local user can read it for as long as the command runs.
|
|
89
|
+
Neither disclosure can be undone: an Ed25519 signing key *is* the identity it asserts, and a playlist's
|
|
90
|
+
`curators[]` owner set is immutable, so a leaked key cannot be rotated out of a document that already
|
|
91
|
+
names it. `--key-file` keeps the material in a file you control:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
ff-cli status --key-file ~/.config/ff-cli/signing.key # whose key is this?
|
|
95
|
+
ff-cli sign playlist.json -r curator --key-file ~/.config/ff-cli/signing.key
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Keep the key file private** — `chmod 600` it, in a directory only you can enter. The CLI does not
|
|
99
|
+
check its permissions and will not refuse a world-readable one: a mode check cannot see the directory
|
|
100
|
+
above the file, cannot describe a Windows ACL, and would refuse paths that are perfectly appropriate,
|
|
101
|
+
so it would be ceremony rather than protection.
|
|
102
|
+
|
|
103
|
+
The rules on both flags:
|
|
104
|
+
|
|
105
|
+
- **Passing both is an error**, not a precedence rule. A precedence rule has to silently ignore one of
|
|
106
|
+
the two keys you supplied, and a key you believe was used and was not is the failure this CLI keeps
|
|
107
|
+
removing.
|
|
108
|
+
- **An empty `--key`** — what `--key "$SIGNING_KEY"` becomes when the variable is unset — is rejected,
|
|
109
|
+
never treated as absent.
|
|
110
|
+
- **A `--key-file` that is missing, unreadable, or holds no key material** is rejected the same way.
|
|
111
|
+
Neither ever falls back to the configured key: signing under an identity you did not choose is not
|
|
112
|
+
something a publish or a delete can be taken back from.
|
|
113
|
+
- **A `--key-file` argument is always opened, whatever it looks like.** A directory really can be
|
|
114
|
+
called `BEGIN`, and a key file really can be named after a content hash. What is gated is not the
|
|
115
|
+
open but the *message*: a failure names the path only when it is short and no part of it is a
|
|
116
|
+
complete key encoding, and otherwise says it could not be read without repeating it. Typing the key
|
|
117
|
+
where the path goes — one keystroke from `--key` — is reported as that mistake, by name, with the
|
|
118
|
+
value withheld.
|
|
119
|
+
- **The key is never echoed**, in any error, in any encoding — nor is a `--key-file` argument that may
|
|
120
|
+
be one.
|
|
121
|
+
|
|
68
122
|
### Generate an Ed25519 private key
|
|
69
123
|
|
|
70
124
|
The simplest path is `ff-cli setup`, which generates a key for you (base64 PKCS#8 DER) and writes it to your config. To generate one yourself:
|
|
@@ -142,7 +196,9 @@ No credential is configured for any of these. The feed has no API key: every wri
|
|
|
142
196
|
signatures in the request body, and an `apiKey` left in an existing config is ignored.
|
|
143
197
|
|
|
144
198
|
`playlist.privateKey` is needed only where the CLI itself has to sign at request time — `publish
|
|
145
|
-
--replace` and `unpublish`, which sign the owner-bound intent that authorizes the write.
|
|
199
|
+
--replace` and `unpublish`, which sign the owner-bound intent that authorizes the write. Both accept
|
|
200
|
+
`-k, --key` or `--key-file <path>` to use a different key for one run, so a second owner identity does
|
|
201
|
+
not require editing this file. A plain
|
|
146
202
|
`publish` signs nothing: it uploads the `signatures[]` envelope the document already carries, so it
|
|
147
203
|
works with no key configured at all (the document must have been signed at some point, by
|
|
148
204
|
`ff-cli sign` or by whoever produced it, but not by this command).
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -546,8 +546,9 @@ documents you sign by hand, where `playlist.role` (default `agent`) decides the
|
|
|
546
546
|
## Replace or Delete a Published Playlist
|
|
547
547
|
|
|
548
548
|
A feed's `PUT` and `DELETE` are **owner-bound**, and neither accepts an API key. Both carry a short-lived
|
|
549
|
-
signed **intent** — `ff-cli` builds it, signs it
|
|
550
|
-
|
|
549
|
+
signed **intent** — `ff-cli` builds it, signs it in the `curator` role with the configured
|
|
550
|
+
`playlist.privateKey` (or with `-k, --key` when you name one for that command), and sends it alongside
|
|
551
|
+
(replace) or as (delete) the request body. Only a key the **stored** playlist names in
|
|
551
552
|
`curators[]` can authorize either. This is the whole reason `publish` refuses a document with no
|
|
552
553
|
owner-role signature: once such a playlist is created it can never be replaced or deleted.
|
|
553
554
|
|
|
@@ -588,6 +589,14 @@ If you edited this playlist after it was signed, the existing signatures no long
|
|
|
588
589
|
Signing fresh drops the feed's entry along with the curator's, which is correct: it covers the pre-edit
|
|
589
590
|
content too, and the feed appends a new one of its own after it verifies the replacement.
|
|
590
591
|
|
|
592
|
+
**On a co-curated playlist this drops the other curators' signatures too.** The command verifies every
|
|
593
|
+
discarded entry against the document as it now stands and names each one, so you can see whose
|
|
594
|
+
signatures will not be on what you publish. Entries that still verify — all of them, if you have not
|
|
595
|
+
actually edited anything — are reported as removed; keep the previous file if you want them back.
|
|
596
|
+
Entries that do not verify are reported as exactly that, with no explanation attached: this command sees
|
|
597
|
+
only the document as it stands, so it cannot tell a changed document from a signature that was never
|
|
598
|
+
valid.
|
|
599
|
+
|
|
591
600
|
```
|
|
592
601
|
$ ff-cli sign playlist.json -r curator --replace-signatures
|
|
593
602
|
|
|
@@ -596,10 +605,130 @@ Sign playlist
|
|
|
596
605
|
✓ Playlist signed and saved to: /path/to/playlist.json
|
|
597
606
|
|
|
598
607
|
Playlist signed
|
|
599
|
-
Replaced
|
|
608
|
+
Replaced 3 existing signatures:
|
|
609
|
+
- a signature claiming your key (curator, ...Dy8sokwC) — removed; could not be verified against this document
|
|
610
|
+
- another key's signature (curator, ...L5ufrpW3) — removed; could not be verified against this document
|
|
611
|
+
- another key's signature (feed, ...1Rq7Lzik) — removed; could not be verified against this document
|
|
612
|
+
3 other signatures could not be verified against this document:
|
|
613
|
+
...Dy8sokwC (curator) — claims your key, unverified
|
|
614
|
+
...L5ufrpW3 (curator)
|
|
615
|
+
...1Rq7Lzik (feed)
|
|
616
|
+
That is consistent with the content having changed since they were made, and
|
|
617
|
+
equally with their never having been valid — this command only has the document
|
|
618
|
+
as it stands, so it cannot tell which. If you want those signatures on what you
|
|
619
|
+
publish, their holders have to sign this document; signing appends, so they can
|
|
620
|
+
add to this file without disturbing yours.
|
|
621
|
+
A feed appends its own signature again after it verifies a replacement.
|
|
622
|
+
Signatures: 1
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
**Your own earlier entry is in that list, and named as a claim rather than as yours.** It was made over
|
|
626
|
+
the content before the edit, so it no longer verifies — and an entry that does not verify has not
|
|
627
|
+
established whose it is. Reading a `kid` as proof of authorship is how a forged entry would hide behind
|
|
628
|
+
your identity, so the command says what it checked: this signature claims your key, and could not be
|
|
629
|
+
confirmed. On an unedited document the same entry verifies and is reported plainly as replaced.
|
|
630
|
+
|
|
631
|
+
Four things that report deliberately does **not** say.
|
|
632
|
+
|
|
633
|
+
It does not call an entry *void*. That would assert it verified against the previous content and no
|
|
634
|
+
longer does — and the previous content was edited in place, so it exists nowhere by the time `sign`
|
|
635
|
+
runs. A failed verification is equally consistent with an edit, with a tampered entry, and with one
|
|
636
|
+
that was never valid; the command reports what it checked and offers both explanations.
|
|
637
|
+
|
|
638
|
+
It does not treat `role: "feed"` as the feed's own. Any key can emit that role and the CLI holds no
|
|
639
|
+
feed identity to check a `kid` against. That a feed re-appends its signature after verifying a
|
|
640
|
+
replacement is a general fact, printed as one, not a claim about the entry above it.
|
|
641
|
+
|
|
642
|
+
It does not rewrite anyone's role. `agent`, `institution` and `licensor` are valid DP-1 roles, so each
|
|
643
|
+
entry is named with the role it actually carries.
|
|
644
|
+
|
|
645
|
+
It does not credit an unverified entry to the key it names — not even to yours. Attribution follows
|
|
646
|
+
verification, never the other way round.
|
|
647
|
+
|
|
648
|
+
A flat legacy `signature` gets its own line, because it carries no `kid` and no `role` and the
|
|
649
|
+
multi-signature verifier has nothing to check it with:
|
|
650
|
+
|
|
651
|
+
```
|
|
652
|
+
- a legacy flat signature (no kid, no role) — removed; not checkable here
|
|
653
|
+
A legacy flat signature carries no kid or role, so nothing here can judge it.
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
Running the flag on a document you have **not** edited is a different situation, because nothing was
|
|
657
|
+
invalidated — the signing payload excludes `signatures`, so the discarded entries still cover the
|
|
658
|
+
content. **If any of them belongs to another key, an in-place run refuses:**
|
|
659
|
+
|
|
660
|
+
```
|
|
661
|
+
$ ff-cli sign playlist.json -r curator --replace-signatures
|
|
662
|
+
|
|
663
|
+
Sign playlist
|
|
664
|
+
|
|
665
|
+
Sign failed: This would discard 1 still-valid signature from other keys. Write the result elsewhere so the original stays:
|
|
666
|
+
ff-cli sign playlist.json -r curator --replace-signatures --output <new file>
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
Only its holder could make that signature again, so overwriting the one file that carries it costs
|
|
670
|
+
something this command cannot restore. Writing elsewhere keeps the original exactly where it is:
|
|
671
|
+
|
|
672
|
+
```
|
|
673
|
+
$ ff-cli sign playlist.json -r curator --replace-signatures -o out.json
|
|
674
|
+
|
|
675
|
+
Sign playlist
|
|
676
|
+
|
|
677
|
+
✓ Playlist signed and saved to: /path/to/out.json
|
|
678
|
+
|
|
679
|
+
Playlist signed
|
|
680
|
+
Replaced 2 existing signatures:
|
|
681
|
+
- your own earlier signature (curator, ...XppfYVjB) — replaced by this signing
|
|
682
|
+
- another key's signature (curator, ...UNFmeU8D) — removed; still valid over this content
|
|
683
|
+
1 other signature still verified over this content and was removed anyway.
|
|
684
|
+
Your input file is untouched, so it remains valid there.
|
|
685
|
+
A feed appends its own signature again after it verifies a replacement.
|
|
600
686
|
Signatures: 1
|
|
601
687
|
```
|
|
602
688
|
|
|
689
|
+
The refusal is narrow: it is about what cannot be recovered, not about how many signatures go. An
|
|
690
|
+
in-place run still proceeds when the only entries dropped are **your own** — sign again to add any of
|
|
691
|
+
them back — or ones that **no longer verify**, which the input could not restore either.
|
|
692
|
+
|
|
693
|
+
**`sign` overwrites only a document it has read.** Before writing, it reads the destination back and
|
|
694
|
+
compares it with the document being signed. Equal means this is that document, wherever the name now
|
|
695
|
+
points, and the rule above applies to it. Different means the destination holds something never
|
|
696
|
+
inspected, and the run is refused:
|
|
697
|
+
|
|
698
|
+
```
|
|
699
|
+
$ ff-cli sign playlist.json -r curator --replace-signatures -o notes.json
|
|
700
|
+
|
|
701
|
+
Sign failed: notes.json already exists and is not the playlist being signed; choose a new name, or pass --force to overwrite it.
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
`--force` allows it, and the report names what it replaced. This is a content rule, not an identity
|
|
705
|
+
one: no comparison of paths or inodes can promise that the file about to be truncated is the file that
|
|
706
|
+
was read, because a name can be re-pointed between any two system calls — but comparing the bytes
|
|
707
|
+
through the descriptor being written can. A destination that already exists needs read permission for
|
|
708
|
+
that reason.
|
|
709
|
+
|
|
710
|
+
**It narrows the race; it does not close it.** The read and the overwrite are two operations, and an
|
|
711
|
+
editor writing in the same instant can still lose its change — every tool that edits a file in place
|
|
712
|
+
carries this, and nothing in userspace removes it portably. A fresh `--output` name is the write that
|
|
713
|
+
cannot collide, because nothing is there to lose.
|
|
714
|
+
|
|
715
|
+
If the destination turns out to hold the **same** document as the input — a copy taken with `cp`, say —
|
|
716
|
+
it is treated exactly as an in-place run, because without file identity a copy and a second name for the
|
|
717
|
+
input are the same thing. So the same narrow rule decides it: the run is refused **only when it would
|
|
718
|
+
discard a still-valid signature from another key**. That is the whole of what the refusal protects, and
|
|
719
|
+
a copy carrying only your own signatures, or only ones that no longer verify, is simply written — the
|
|
720
|
+
input is a different file and is not touched either way.
|
|
721
|
+
|
|
722
|
+
When it does refuse, `--force` deliberately does **not** override it: force is for replacing a different
|
|
723
|
+
document, never for destroying a signature only its holder could make again. Delete the copy and re-run,
|
|
724
|
+
or pick another `--output` name; a name that does not exist is written with no check at all.
|
|
725
|
+
|
|
726
|
+
The rule holds on every platform and assumes nothing about the filesystem. An earlier version kept a
|
|
727
|
+
copy of the input instead; that requires reproducing the source's access, which is not portable — POSIX
|
|
728
|
+
ACLs grant what mode bits do not describe, macOS extended ACLs are not constrained by the mask, and
|
|
729
|
+
Windows mode bits constrain nothing — so every implementation of it was a way for the copy to disclose
|
|
730
|
+
the document. Refusing needs none of that.
|
|
731
|
+
|
|
603
732
|
Appending stays the default, because it is right whenever the content has not changed — a second curator
|
|
604
733
|
co-signing an unedited playlist keeps the first endorsement, and the payload hash excludes `signatures`
|
|
605
734
|
so the earlier entry stays valid.
|
|
@@ -657,6 +786,71 @@ Without `-y`, `unpublish` shows the title and the server and asks, defaulting to
|
|
|
657
786
|
tombstones the id: the playlist cannot be restored, and a later publish naming that id is refused. Build
|
|
658
787
|
a new playlist instead of trying to recreate it.
|
|
659
788
|
|
|
789
|
+
### Signing with a key other than the configured one
|
|
790
|
+
|
|
791
|
+
The configured `playlist.privateKey` is the default, not the only option. `unpublish` and
|
|
792
|
+
`publish --replace` take `-k, --key` and `--key-file <path>`, like `ff-cli sign` and `ff-cli status`,
|
|
793
|
+
and either one overrides that default both for the intent signature and for the local ownership check —
|
|
794
|
+
so holding a second owner key no longer means editing `config.json`:
|
|
795
|
+
|
|
796
|
+
```bash
|
|
797
|
+
ff-cli unpublish <id> -s 0 --key <private key for a stored owner>
|
|
798
|
+
ff-cli publish playlist.json --replace -s 0 --key <private key for a stored owner>
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
`ff-cli status --key <private key>` reports which identity a key carries, which is how you check it
|
|
802
|
+
against the `Keys that have proved ownership:` list in a refusal. `unpublish` also prints `Signing as:`, both in its
|
|
803
|
+
confirmation and under `-y`, so the identity that performs an irreversible delete is always in the
|
|
804
|
+
record. It resolves that key once, before the lookup and before the prompt, and signs with the same
|
|
805
|
+
material — editing `config.json` while a confirmation is open cannot change who the delete runs as.
|
|
806
|
+
|
|
807
|
+
#### Keeping the key off the command line
|
|
808
|
+
|
|
809
|
+
`--key` puts the private key in your shell history file and in the process list, where any local user
|
|
810
|
+
can read it while the command runs. `--key-file <path>` reads the same material from a file instead, on
|
|
811
|
+
all four commands, with the same key encodings:
|
|
812
|
+
|
|
813
|
+
```bash
|
|
814
|
+
ff-cli status --key-file ~/.config/ff-cli/owner.key # whose key is this?
|
|
815
|
+
ff-cli sign playlist.json -r curator --key-file ~/.config/ff-cli/owner.key
|
|
816
|
+
ff-cli unpublish <id> -s 0 --key-file ~/.config/ff-cli/owner.key
|
|
817
|
+
ff-cli publish playlist.json --replace -s 0 --key-file ~/.config/ff-cli/owner.key
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
Keep the file private (`chmod 600`, in a directory only you can enter) — the CLI does not check its
|
|
821
|
+
permissions. Passing `--key` and `--key-file` together is an error rather than a precedence rule, and a
|
|
822
|
+
key file that is missing, unreadable, or empty is refused rather than quietly falling back to the
|
|
823
|
+
configured key:
|
|
824
|
+
|
|
825
|
+
```
|
|
826
|
+
$ ff-cli unpublish <id> -y --key-file ./owner.key # file was truncated
|
|
827
|
+
|
|
828
|
+
Cannot sign the delete
|
|
829
|
+
The key file at ./owner.key holds no key material. Refusing rather than falling back to
|
|
830
|
+
the configured key: a signature made under an identity you did not choose is not something
|
|
831
|
+
a publish or a delete can be taken back from.
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
A refusal names the key you actually used. If it came from `--key`, the retry says `--key`; if it came
|
|
835
|
+
from `--key-file`, the retry says `--key-file`; it will not send you to `playlist.privateKey` for a run
|
|
836
|
+
that never read it.
|
|
837
|
+
|
|
838
|
+
An empty `--key` is **rejected**, not ignored:
|
|
839
|
+
|
|
840
|
+
```
|
|
841
|
+
$ ff-cli unpublish <id> -y --key "$SIGNING_KEY" # variable unset
|
|
842
|
+
|
|
843
|
+
Cannot sign the delete
|
|
844
|
+
The --key value is empty. This usually means a shell variable did not expand
|
|
845
|
+
(for example --key "$SIGNING_KEY" with SIGNING_KEY unset). Refusing rather than falling
|
|
846
|
+
back to the configured key: a signature made by the wrong identity is not something a
|
|
847
|
+
delete can be taken back from.
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
A plain `publish` **refuses** `--key` and `--key-file` rather than ignoring them: it signs nothing at
|
|
851
|
+
request time, so the flag would do nothing. `ff-cli fetch` has neither for the same reason — it is a
|
|
852
|
+
read.
|
|
853
|
+
|
|
660
854
|
### When ownership cannot be proved
|
|
661
855
|
|
|
662
856
|
A key counts as an owner only when the **stored** playlist names it in `curators[]` **and** carries its
|
|
@@ -700,17 +894,23 @@ Unpublish playlist
|
|
|
700
894
|
Unpublish failed
|
|
701
895
|
The configured signing key is not an owner of this playlist, so it cannot delete it.
|
|
702
896
|
|
|
703
|
-
Only a key the stored playlist names in curators[]
|
|
704
|
-
ownership from the stored document, not from a local copy.
|
|
897
|
+
Only a key the stored playlist names in curators[] AND that signed it as "curator" can
|
|
898
|
+
authorize a delete; the feed derives ownership from the stored document, not from a local copy.
|
|
705
899
|
Your configured identity:
|
|
706
900
|
did:key:z6MkoX8i2dynyvLh4hUHZt8b42q9uAwwCWxM4NSX4YDfMtaC
|
|
707
|
-
|
|
901
|
+
Keys that have proved ownership:
|
|
708
902
|
did:key:z6MkoDkq5YXsFGXPiD6HDUVfze5mvhU5QF4hTy59pVVPYg82
|
|
709
|
-
Point playlist.privateKey at
|
|
710
|
-
"ff-cli status --key <private key>").
|
|
711
|
-
|
|
903
|
+
Point playlist.privateKey at the key listed above, or pass one for this run with --key (confirm
|
|
904
|
+
any key's identity with "ff-cli status --key <private key>").
|
|
905
|
+
Ownership cannot be granted after the fact: the owner set is immutable, so a playlist signed
|
|
906
|
+
by the wrong key stays that way.
|
|
712
907
|
```
|
|
713
908
|
|
|
909
|
+
That run used the configured key, so the refusal names it as configured. Had it been given
|
|
910
|
+
`--key`, the same refusal would name that key instead and the retry line would read
|
|
911
|
+
`Run it again with an owner key: --key <private key for the key listed above>` — a refusal always
|
|
912
|
+
points at the key the command actually used, never at a config file the run never read.
|
|
913
|
+
|
|
714
914
|
Ownership cannot be granted after the fact — the owner set is immutable, and only an owner could change
|
|
715
915
|
it — so the only fix is to hold a declared key. A playlist whose stored `curators[]` is **empty** is a
|
|
716
916
|
harder case with the same shape: nobody owns it, no signature can ever authorize a write, and `ff-cli`
|
package/docs/PROJECT_SPEC.md
CHANGED
|
@@ -191,8 +191,11 @@ accepts an API key. Both therefore carry a signed intent — `{ action, target:
|
|
|
191
191
|
created, signatures }`, with a `payloadHash` for replace — whose `created` must fall inside the feed's
|
|
192
192
|
freshness window; the intent exists because a document's own signatures are public via `GET` and could
|
|
193
193
|
otherwise be replayed to roll a resource back. The CLI proves ownership before it signs anything: it
|
|
194
|
-
`GET`s the stored playlist and requires the
|
|
195
|
-
|
|
194
|
+
`GET`s the stored playlist and requires the signing key — the configured `playlist.privateKey` unless
|
|
195
|
+
`-k, --key` or `--key-file <path>` overrides it for that command, the latter keeping the key out of the
|
|
196
|
+
shell history and the process list (`CONFIGURATION.md`, "Where the signing key can come from") — to be
|
|
197
|
+
both named in the stored `curators[]` and the signer of
|
|
198
|
+
a cryptographically valid `curator`-role signature over that stored document. Being
|
|
196
199
|
named is a claim; the owner-role signature is the proof, and checking only the claim is what would let a
|
|
197
200
|
legacy `agent`-signed document pass preflight and come back as a `403` reported as a missing
|
|
198
201
|
declaration — the one thing not wrong with it. The three local refusals are therefore distinct: no
|
|
@@ -203,6 +206,35 @@ create a document whose declared curator did not sign in the `curator` role: tha
|
|
|
203
206
|
recoverable and the resulting stored playlist would not be. A delete tombstones the id, so the operation
|
|
204
207
|
is final and the id is not reusable.
|
|
205
208
|
|
|
209
|
+
A `--replace-signatures` run writing over its own input refuses when it would discard a still-valid
|
|
210
|
+
signature from another key. Only that key's holder could produce it again, so overwriting the one file
|
|
211
|
+
that carries it destroys something the command cannot restore; `--output` writes the result elsewhere
|
|
212
|
+
and leaves the original in place. The refusal is scoped to what is unrecoverable — an in-place run still
|
|
213
|
+
proceeds when the entries dropped are the signer's own, or no longer verify.
|
|
214
|
+
|
|
215
|
+
Refusal rather than a preserved copy is deliberate. A copy has to reproduce the source's access, and no
|
|
216
|
+
portable means of doing so exists: POSIX ACLs grant what the mode bits do not describe, macOS extended
|
|
217
|
+
ACLs are not constrained by the mask, and Windows mode bits constrain nothing at all. Every version of
|
|
218
|
+
that copy was a way to disclose the document. The refusal assumes nothing about the filesystem, so the
|
|
219
|
+
invariant — a still-valid endorsement is never overwritten in place — holds identically everywhere.
|
|
220
|
+
|
|
221
|
+
Which document a destination holds is decided by content, not by file identity. The destination is read
|
|
222
|
+
back through the descriptor that will write it and compared with the document being signed; a
|
|
223
|
+
destination holding anything else is refused unless `--force` says otherwise. Identity cannot answer
|
|
224
|
+
this — a name can be re-pointed between any two system calls, so no comparison of paths or inodes
|
|
225
|
+
describes what a later write will land on — while the bytes behind a descriptor can. It narrows the
|
|
226
|
+
race and does not close it: the read and the overwrite are two operations, and an editor writing in the
|
|
227
|
+
same instant can still lose its change, as with any tool that edits in place. A `--output` name that
|
|
228
|
+
does not yet exist is the write that cannot collide.
|
|
229
|
+
|
|
230
|
+
A destination holding the *same* document as the input is treated as an in-place run, since without
|
|
231
|
+
identity a copy and a second name for the input are indistinguishable — and so the same narrow rule
|
|
232
|
+
applies: it is refused only when the run would discard a still-valid signature from another key. A copy
|
|
233
|
+
carrying only the signer's own entries, or only unverifiable ones, is written like any other
|
|
234
|
+
destination; the input is a separate file and is untouched. Where the refusal does fire, `--force` does
|
|
235
|
+
not reach it by design: force authorizes replacing a different document, never destroying a signature
|
|
236
|
+
only its holder could reproduce.
|
|
237
|
+
|
|
206
238
|
Editing a published playlist therefore has a required shape: `fetch` the stored document, change it,
|
|
207
239
|
re-sign with `sign --replace-signatures`, then `publish --replace`. `fetch` exists because every other
|
|
208
240
|
way of producing that starting file is wrong: `find` and `build` mint a new identity, and `verify` only
|
|
@@ -362,10 +394,17 @@ and nothing else. `enrich` is the repair path for those.
|
|
|
362
394
|
a curator-restricted playlist is never briefly world-readable mid-write. The
|
|
363
395
|
destination's owner and group are carried across too, since a replacement is
|
|
364
396
|
a new inode and would otherwise take this process's ownership — silently
|
|
365
|
-
reassigning a shared playlist.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
397
|
+
reassigning a shared playlist. Two things clear set-user-ID and set-group-ID
|
|
398
|
+
— the chown, and an unprivileged write to a regular file — so the mode is
|
|
399
|
+
restored and verified against the inode after each of them, the second time
|
|
400
|
+
with nothing left before the rename that could change it. Where the ownership
|
|
401
|
+
or the mode cannot be reproduced, the in-place replacement is refused and
|
|
402
|
+
`--output` is offered instead. Access-control
|
|
403
|
+
lists are not preserved: a replacement is a new inode, so its ACL comes from
|
|
404
|
+
the directory's default rather than from the file being replaced, and mode
|
|
405
|
+
bits cannot carry named entries across on any platform — a playlist whose
|
|
406
|
+
access depends on an ACL should be enriched through `--output` to a fresh
|
|
407
|
+
name in a directory whose access is what the result should have.
|
|
369
408
|
- `--output` names a file the caller expects to exist afterwards, so it is
|
|
370
409
|
written even when nothing was enriched. A no-op without `--output` writes
|
|
371
410
|
nothing rather than rewriting the input for no gain.
|