001/* 002 * SPDX-FileCopyrightText: none 003 * SPDX-License-Identifier: CC0-1.0 004 */ 005 006package gov.nist.secauto.oscal.tools.cli.core.commands; 007 008import gov.nist.secauto.metaschema.cli.processor.CLIProcessor.CallingContext; 009import gov.nist.secauto.metaschema.cli.processor.ExitCode; 010import gov.nist.secauto.metaschema.cli.processor.ExitStatus; 011import gov.nist.secauto.metaschema.cli.processor.InvalidArgumentException; 012import gov.nist.secauto.metaschema.cli.processor.OptionUtils; 013import gov.nist.secauto.metaschema.cli.processor.command.AbstractTerminalCommand; 014import gov.nist.secauto.metaschema.cli.processor.command.DefaultExtraArgument; 015import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument; 016import gov.nist.secauto.metaschema.cli.processor.command.ICommandExecutor; 017import gov.nist.secauto.metaschema.core.metapath.DynamicContext; 018import gov.nist.secauto.metaschema.core.metapath.item.node.IDocumentNodeItem; 019import gov.nist.secauto.metaschema.core.metapath.item.node.INodeItem; 020import gov.nist.secauto.metaschema.core.util.CustomCollectors; 021import gov.nist.secauto.metaschema.core.util.ObjectUtils; 022import gov.nist.secauto.metaschema.databind.io.DeserializationFeature; 023import gov.nist.secauto.metaschema.databind.io.Format; 024import gov.nist.secauto.metaschema.databind.io.IBoundLoader; 025import gov.nist.secauto.metaschema.databind.io.ISerializer; 026import gov.nist.secauto.oscal.lib.OscalBindingContext; 027import gov.nist.secauto.oscal.lib.model.Catalog; 028import gov.nist.secauto.oscal.lib.model.Profile; 029import gov.nist.secauto.oscal.lib.profile.resolver.ProfileResolutionException; 030import gov.nist.secauto.oscal.lib.profile.resolver.ProfileResolver; 031 032import org.apache.commons.cli.CommandLine; 033import org.apache.commons.cli.Option; 034 035import java.io.File; 036import java.io.FileNotFoundException; 037import java.io.IOException; 038import java.io.PrintStream; 039import java.nio.file.Files; 040import java.nio.file.Path; 041import java.nio.file.Paths; 042import java.util.Arrays; 043import java.util.Collection; 044import java.util.List; 045import java.util.Locale; 046 047import edu.umd.cs.findbugs.annotations.NonNull; 048 049public abstract class AbstractResolveCommand 050 extends AbstractTerminalCommand { 051 @NonNull 052 private static final List<ExtraArgument> EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of( 053 new DefaultExtraArgument("file to resolve", true), 054 new DefaultExtraArgument("destination file", false))); 055 @NonNull 056 private static final Option AS_OPTION = ObjectUtils.notNull( 057 Option.builder() 058 .longOpt("as") 059 .hasArg() 060 .argName("FORMAT") 061 .desc("source format: xml, json, or yaml") 062 .build()); 063 @NonNull 064 private static final Option TO_OPTION = ObjectUtils.notNull( 065 Option.builder() 066 .longOpt("to") 067 .required() 068 .hasArg().argName("FORMAT") 069 .desc("convert to format: xml, json, or yaml") 070 .build()); 071 @NonNull 072 private static final Option OVERWRITE_OPTION = ObjectUtils.notNull( 073 Option.builder() 074 .longOpt("overwrite") 075 .desc("overwrite the destination if it exists") 076 .build()); 077 @NonNull 078 private static final List<Option> OPTIONS = ObjectUtils.notNull( 079 List.of( 080 AS_OPTION, 081 TO_OPTION, 082 OVERWRITE_OPTION)); 083 084 @Override 085 public String getDescription() { 086 return "Resolve the specified OSCAL Profile"; 087 } 088 089 @Override 090 public Collection<? extends Option> gatherOptions() { 091 return OPTIONS; 092 } 093 094 @Override 095 public List<ExtraArgument> getExtraArguments() { 096 return EXTRA_ARGUMENTS; 097 } 098 099 @SuppressWarnings({ 100 "PMD.CyclomaticComplexity", "PMD.CognitiveComplexity", // reasonable 101 "PMD.PreserveStackTrace" // intended 102 }) 103 @Override 104 public void validateOptions(CallingContext callingContext, CommandLine cmdLine) throws InvalidArgumentException { 105 if (cmdLine.hasOption(AS_OPTION)) { 106 try { 107 String toFormatText = cmdLine.getOptionValue(AS_OPTION); 108 Format.valueOf(toFormatText.toUpperCase(Locale.ROOT)); 109 } catch (IllegalArgumentException ex) { 110 InvalidArgumentException newEx = new InvalidArgumentException( 111 String.format("Invalid '%s' argument. The format must be one of: %s.", 112 OptionUtils.toArgument(AS_OPTION), 113 Arrays.asList(Format.values()).stream() 114 .map(Enum::name) 115 .collect(CustomCollectors.joiningWithOxfordComma("and")))); 116 newEx.setOption(AS_OPTION); 117 newEx.addSuppressed(ex); 118 throw newEx; 119 } 120 } 121 122 if (cmdLine.hasOption(TO_OPTION)) { 123 try { 124 String toFormatText = cmdLine.getOptionValue(TO_OPTION); 125 Format.valueOf(toFormatText.toUpperCase(Locale.ROOT)); 126 } catch (IllegalArgumentException ex) { 127 InvalidArgumentException newEx 128 = new InvalidArgumentException("Invalid '--to' argument. The format must be one of: " 129 + Arrays.asList(Format.values()).stream() 130 .map(Enum::name) 131 .collect(CustomCollectors.joiningWithOxfordComma("and"))); 132 newEx.setOption(AS_OPTION); 133 newEx.addSuppressed(ex); 134 throw newEx; 135 } 136 } 137 138 List<String> extraArgs = cmdLine.getArgList(); 139 if (extraArgs.isEmpty()) { 140 throw new InvalidArgumentException("The source to resolve must be provided."); 141 } 142 143 File source = new File(extraArgs.get(0)); 144 if (!source.exists()) { 145 throw new InvalidArgumentException("The provided source '" + source.getPath() + "' does not exist."); 146 } 147 if (!source.canRead()) { 148 throw new InvalidArgumentException("The provided source '" + source.getPath() + "' is not readable."); 149 } 150 } 151 152 @Override 153 public ICommandExecutor newExecutor(CallingContext callingContext, CommandLine cmdLine) { 154 return ICommandExecutor.using(callingContext, cmdLine, this::executeCommand); 155 } 156 157 @SuppressWarnings({ 158 "PMD.OnlyOneReturn", // readability 159 "unused" 160 }) 161 protected ExitStatus executeCommand( 162 @NonNull CallingContext callingContext, 163 @NonNull CommandLine cmdLine) { 164 List<String> extraArgs = cmdLine.getArgList(); 165 Path source = resolvePathAgainstCWD(ObjectUtils.notNull(Paths.get(extraArgs.get(0)))); 166 167 IBoundLoader loader = OscalBindingContext.instance().newBoundLoader(); 168 loader.disableFeature(DeserializationFeature.DESERIALIZE_VALIDATE_CONSTRAINTS); 169 170 Format asFormat; 171 // attempt to determine the format 172 if (cmdLine.hasOption(AS_OPTION)) { 173 try { 174 String asFormatText = cmdLine.getOptionValue(AS_OPTION); 175 asFormat = Format.valueOf(asFormatText.toUpperCase(Locale.ROOT)); 176 } catch (IllegalArgumentException ex) { 177 return ExitCode.INVALID_ARGUMENTS 178 .exitMessage("Invalid '--as' argument. The format must be one of: " + Arrays.stream(Format.values()) 179 .map(Enum::name) 180 .collect(CustomCollectors.joiningWithOxfordComma("or"))); 181 } 182 } else { 183 // attempt to determine the format 184 try { 185 asFormat = loader.detectFormat(ObjectUtils.notNull(source)); 186 } catch (FileNotFoundException ex) { 187 // this case was already checked for 188 return ExitCode.IO_ERROR.exitMessage("The provided source file '" + source + "' does not exist."); 189 } catch (IOException ex) { 190 return ExitCode.PROCESSING_ERROR.exit().withThrowable(ex); 191 } catch (IllegalArgumentException ex) { 192 return ExitCode.INVALID_ARGUMENTS.exitMessage( 193 "Source file has unrecognizable format. Use '--as' to specify the format. The format must be one of: " 194 + Arrays.stream(Format.values()) 195 .map(Enum::name) 196 .collect(CustomCollectors.joiningWithOxfordComma("or"))); 197 } 198 } 199 200 source = source.toAbsolutePath(); 201 assert source != null; 202 203 Format toFormat; 204 if (cmdLine.hasOption(TO_OPTION)) { 205 String toFormatText = cmdLine.getOptionValue(TO_OPTION); 206 toFormat = Format.valueOf(toFormatText.toUpperCase(Locale.ROOT)); 207 } else { 208 toFormat = asFormat; 209 } 210 211 Path destination = null; 212 if (extraArgs.size() == 2) { 213 destination = Paths.get(extraArgs.get(1)).toAbsolutePath(); 214 } 215 216 if (destination != null) { 217 if (Files.exists(destination)) { 218 if (!cmdLine.hasOption(OVERWRITE_OPTION)) { 219 return ExitCode.INVALID_ARGUMENTS.exitMessage("The provided destination '" + destination 220 + "' already exists and the --overwrite option was not provided."); 221 } 222 if (!Files.isWritable(destination)) { 223 return ExitCode.IO_ERROR.exitMessage("The provided destination '" + destination + "' is not writable."); 224 } 225 } else { 226 Path parent = destination.getParent(); 227 if (parent != null) { 228 try { 229 Files.createDirectories(parent); 230 } catch (IOException ex) { 231 return ExitCode.INVALID_TARGET.exit().withThrowable(ex); 232 } 233 } 234 } 235 } 236 237 IDocumentNodeItem document; 238 try { 239 document = loader.loadAsNodeItem(asFormat, source); 240 } catch (IOException ex) { 241 return ExitCode.IO_ERROR.exit().withThrowable(ex); 242 } 243 Object object = document.getValue(); 244 if (object == null) { 245 return ExitCode.INVALID_ARGUMENTS.exitMessage("The target profile contained no data"); 246 } 247 248 if (object instanceof Catalog) { 249 // this is a catalog 250 return ExitCode.INVALID_ARGUMENTS.exitMessage("The target is already a catalog"); 251 } 252 253 if (!(object instanceof Profile)) { 254 // this is something else 255 return ExitCode.INVALID_ARGUMENTS.exitMessage("The target is not a profile"); 256 } 257 258 // this is a profile 259 DynamicContext dynamicContext = new DynamicContext(document.getStaticContext()); 260 dynamicContext.setDocumentLoader(loader); 261 ProfileResolver resolver = new ProfileResolver(); 262 resolver.setDynamicContext(dynamicContext); 263 264 IDocumentNodeItem resolvedProfile; 265 try { 266 resolvedProfile = resolver.resolve(document); 267 } catch (IOException | ProfileResolutionException ex) { 268 return ExitCode.PROCESSING_ERROR 269 .exitMessage( 270 String.format("Cmd: Unable to resolve profile '%s'. %s", document.getDocumentUri(), ex.getMessage())) 271 .withThrowable(ex); 272 } 273 274 // DefaultConstraintValidator validator = new 275 // DefaultConstraintValidator(dynamicContext); 276 // ((IBoundXdmNodeItem)resolvedProfile).validate(validator); 277 // validator.finalizeValidation(); 278 279 ISerializer<Catalog> serializer 280 = OscalBindingContext.instance().newSerializer(toFormat, Catalog.class); 281 try { 282 if (destination == null) { 283 @SuppressWarnings({ "resource", "PMD.CloseResource" }) PrintStream stdOut = ObjectUtils.notNull(System.out); 284 serializer.serialize((Catalog) INodeItem.toValue(resolvedProfile), stdOut); 285 } else { 286 serializer.serialize((Catalog) INodeItem.toValue(resolvedProfile), destination); 287 } 288 } catch (IOException ex) { 289 return ExitCode.PROCESSING_ERROR.exit().withThrowable(ex); 290 } 291 return ExitCode.OK.exit(); 292 } 293}