1 // TODO: move a lot of the stuff that is currently under "Fig/" out of it
2 // (original intent was to have a separation between generic stuff and
3 // configuration-framework-specific entities. But in practice, the use of global
4 // state and the amount of coupling we have between different modules means we
5 // may as well consider them all to be equal citizens.
6 import {promises as fs} from 'fs';
7 import {dirname} from 'path';
9 import ErrorWithMetadata from '../ErrorWithMetadata';
10 import stat from '../stat';
12 // TODO: decide whether the Ansible definition of "force" (which we use below)
13 // is the one that we want to actual stick with.
16 * Summary of differences between actual and desired state of a file-system
17 * object (ie. a file, directory, or link).
21 * - error: Set if there is some reason why the comparison could not be
22 * completed or the desired end-state cannot be produced (for
23 * example, if we desired an object with a "state" of "file" at
24 * "a/b/c", but there is already a file at "a/b", that will be an
26 * - force: When `true`, user is asking to create a symlink even if the
27 * target doesn't exist, and even if there is a file already at the
28 * destination (which would need to be removed for link creation to
29 * succeed). In the result, set to `true` to signal that a target
30 * will need to be removed (for example, to replace a link with a
31 * directory), or force-written (for example, to replace a file with
32 * a link; ie. `ln -sf`) etc.
33 * - path: Read-only property, for book-keeping purposes. The only
34 * non-optional property.
35 * - state: A required property, but this one has a default ("file").
37 * In general, if a property is unset, that means no changes are
38 * required with respect to that property.
47 readonly path: string;
48 state?: 'absent' | 'directory' | 'file' | 'link' | 'touch';
51 const {stringify} = JSON;
54 * Given a desired end-state of a file-system object (ie. a file,
55 * directory, or link), returns a "diff" of the changes that need to be
56 * made to the current file system to produce that desired end-state.
58 export default async function compare({
69 contents !== undefined &&
70 (state === 'absent' || state === 'directory' || state === 'touch')
72 throw new ErrorWithMetadata(
73 `A file-system object cannot have "contents" if its state is \`${state}\``
77 const diff: Diff = {path};
79 const stats = await stat(path);
81 if (stats instanceof Error) {
85 } else if (stats === null) {
86 // Object does not exist.
87 if (state === 'absent') {
88 // Want "absent", have "absent": no state change required.
90 // Distinguish between `path` itself not existing (in which case
91 // it can be created), and one of its parents not existing (in which case
93 const parent = dirname(path);
94 const stats = await stat(parent);
95 if (stats instanceof Error) {
96 // Unlikely (we were able to stat object but not its parent).
99 } else if (stats === null) {
100 diff.error = new ErrorWithMetadata(
101 `Cannot stat ${stringify(path)} because parent ${stringify(
110 // Nothing else we can check without the object existing.
115 if (state === 'file') {
116 if (stats.type === 'file') {
117 // Want "file", have "file": no state change required.
118 } else if (stats.type === 'link') {
119 // Going to have to overwrite symlink.
122 } else if (stats.type === 'directory') {
123 diff.error = new ErrorWithMetadata(
124 `Cannot replace directory ${stringify(path)} with file`
127 // We're not going to bother with "exotic" types such as sockets etc.
128 diff.error = new ErrorWithMetadata(
129 `Cannot replace object ${stringify(
131 )} of unknown type with file`
135 if (typeof contents === 'string') {
137 const actual = await fs.readFile(path, 'utf8');
138 if (actual !== contents) {
139 diff.contents = contents;
142 // TODO: if this is a perms issue, that might be ok as long as user has
147 if (group && stats.group !== group) {
151 if (owner && stats.owner !== owner) {
154 } else if (state === 'directory') {
155 if (stats.type === 'directory') {
156 // Want "directory", have "directory": no state change required.
157 } else if (stats.type === 'file' || stats.type === 'link') {
159 // Will have to remove file/link before creating directory.
163 const entity = stats.type === 'file' ? 'file' : 'symbolic link';
165 diff.error = new ErrorWithMetadata(
166 `Cannot replace ${entity} ${stringify(
168 )} with directory without 'force'`
172 // We're not going to bother with "exotic" types such as sockets etc.
173 diff.error = new ErrorWithMetadata(
174 `Cannot replace object ${stringify(
176 )} of unknown type with directory`
179 } else if (state === 'link') {
181 } else if (state === 'absent') {
183 } else if (state === 'touch') {